├── .dockerignore ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 01_BUG_REPORT.md │ ├── 02_FEATURE_REQUEST.md │ ├── 03_CODEBASE_IMPROVEMENT.md │ ├── 04_SUPPORT_QUESTION.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── deps │ │ └── action.yml │ ├── docker │ │ └── action.yml │ ├── playwright │ │ └── action.yml │ └── test │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── pr.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── .postcssrc.json ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── biome.json ├── docker ├── Dockerfile ├── docker-compose.yaml └── httpd.conf ├── docs ├── 2023-06.csv ├── 2023-07.csv ├── 2023-08.csv ├── CODE_OF_CONDUCT.md ├── guitos-sample.json ├── guitos-schema.json └── images │ ├── charts-light.png │ ├── charts.png │ ├── history.png │ ├── horizontal-light.png │ ├── horizontal.png │ ├── initial-state-light.png │ ├── initial-state-vertical.png │ ├── initial-state.png │ ├── logo.svg │ ├── vertical-charts-light.png │ ├── vertical-charts.png │ ├── vertical-light.png │ ├── vertical-menu-light.png │ ├── vertical-menu.png │ └── vertical.png ├── e2e ├── accessibility.test.ts ├── happyPath.test.ts └── settingsHappyPath.test.ts ├── index.html ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── public ├── _headers ├── favicon.svg ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.tsx ├── colors.css ├── env.d.ts ├── guitos │ ├── context │ │ ├── BudgetContext.test.tsx │ │ ├── BudgetContext.tsx │ │ ├── ConfigContext.tsx │ │ └── GeneralContext.tsx │ ├── domain │ │ ├── budget.mother.ts │ │ ├── budget.test.ts │ │ ├── budget.ts │ │ ├── budgetItem.mother.ts │ │ ├── budgetItem.ts │ │ ├── budgetRepository.ts │ │ ├── calcHistRepository.ts │ │ ├── calculationHistoryItem.mother.ts │ │ ├── calculationHistoryItem.ts │ │ ├── csvError.mother.ts │ │ ├── csvError.ts │ │ ├── csvItem.ts │ │ ├── expenses.ts │ │ ├── incomes.ts │ │ ├── jsonError.mother.ts │ │ ├── jsonError.ts │ │ ├── objectMother.mother.ts │ │ ├── stats.mother.ts │ │ ├── stats.ts │ │ ├── userOptions.mother.ts │ │ ├── userOptions.test.ts │ │ ├── userOptions.ts │ │ ├── userOptionsRepository.ts │ │ └── uuid.ts │ ├── hooks │ │ ├── useDB.ts │ │ ├── useMove.ts │ │ └── useWindowSize.ts │ ├── infrastructure │ │ ├── localForageBudgetRepository.ts │ │ ├── localForageCalcHistRepository.ts │ │ └── localForageOptionsRepository.ts │ └── sections │ │ ├── Budget │ │ ├── BudgetPage.test.tsx │ │ ├── BudgetPage.tsx │ │ └── __snapshots__ │ │ │ └── BudgetPage.test.tsx.snap │ │ ├── CalculateButton │ │ ├── CalculateButton.css │ │ ├── CalculateButton.test.tsx │ │ ├── CalculateButton.tsx │ │ └── __snapshots__ │ │ │ └── CalculateButton.test.tsx.snap │ │ ├── Chart │ │ ├── Chart.css │ │ ├── Chart.test.tsx │ │ ├── Chart.tsx │ │ ├── ChartTooltip.test.tsx │ │ ├── ChartTooltip.tsx │ │ └── __snapshots__ │ │ │ ├── Chart.test.tsx.snap │ │ │ └── ChartTooltip.test.tsx.snap │ │ ├── ChartsPage │ │ ├── ChartsPage.css │ │ ├── ChartsPage.test.tsx │ │ ├── ChartsPage.tsx │ │ └── __snapshots__ │ │ │ └── ChartsPage.test.tsx.snap │ │ ├── ErrorModal │ │ ├── ErrorModal.css │ │ ├── ErrorModal.test.tsx │ │ ├── ErrorModal.tsx │ │ └── __snapshots__ │ │ │ └── ErrorModal.test.tsx.snap │ │ ├── ItemForm │ │ ├── ItemFormGroup.css │ │ ├── ItemFormGroup.test.tsx │ │ ├── ItemFormGroup.tsx │ │ └── __snapshots__ │ │ │ └── ItemFormGroup.test.tsx.snap │ │ ├── LandingPage │ │ ├── LandingPage.css │ │ ├── LandingPage.test.tsx │ │ ├── LandingPage.tsx │ │ └── __snapshots__ │ │ │ └── LandingPage.test.tsx.snap │ │ ├── Loading │ │ ├── Loading.test.tsx │ │ ├── Loading.tsx │ │ └── __snapshots__ │ │ │ └── Loading.test.tsx.snap │ │ ├── NavBar │ │ ├── NavBar.css │ │ ├── NavBar.test.tsx │ │ ├── NavBar.tsx │ │ ├── NavBarDelete.tsx │ │ ├── NavBarImpExp.tsx │ │ ├── NavBarItem.tsx │ │ ├── NavBarSettings.tsx │ │ └── __snapshots__ │ │ │ └── NavBar.test.tsx.snap │ │ ├── Notification │ │ ├── Notification.css │ │ ├── Notification.test.tsx │ │ ├── Notification.tsx │ │ └── __snapshots__ │ │ │ └── Notification.test.tsx.snap │ │ ├── StatCard │ │ ├── StatCard.css │ │ ├── StatCard.test.tsx │ │ ├── StatCard.tsx │ │ └── __snapshots__ │ │ │ └── StatCard.test.tsx.snap │ │ └── TableCard │ │ ├── TableCard.css │ │ ├── TableCard.test.tsx │ │ ├── TableCard.tsx │ │ └── __snapshots__ │ │ └── TableCard.test.tsx.snap ├── index.css ├── index.tsx ├── lists │ ├── chromeLocalesList.ts │ ├── currenciesList.ts │ ├── currenciesMap.ts │ └── firefoxLocalesList.ts ├── setupTests.ts ├── utils.test.ts └── utils.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | 6 | # testing 7 | /e2e 8 | /coverage 9 | /html 10 | 11 | # production 12 | /build 13 | /docs 14 | .vscode/ 15 | .github/ 16 | 17 | # misc 18 | todo.md 19 | loc.txt 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | npm-debug.log* 27 | yarn-debug.log* 28 | yarn-error.log* 29 | /test-results/ 30 | /playwright-report/ 31 | /blob-report/ 32 | /playwright/.cache/ 33 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @rare-magma 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help guitos to improve 4 | title: "bug: " 5 | labels: "bug" 6 | assignees: "" 7 | --- 8 | 9 | # Bug Report 10 | 11 | **guitos version:** 12 | 13 | 14 | 15 | **Current behavior:** 16 | 17 | 18 | 19 | **Expected behavior:** 20 | 21 | 22 | 23 | **Steps to reproduce:** 24 | 25 | 26 | 27 | **Related code:** 28 | 29 | 30 | 31 | ``` 32 | insert short code snippets here 33 | ``` 34 | 35 | **Other information:** 36 | 37 | 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: "feat: " 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | 9 | # Feature Request 10 | 11 | **Describe the Feature Request** 12 | 13 | 14 | 15 | **Describe Preferred Solution** 16 | 17 | 18 | 19 | **Describe Alternatives** 20 | 21 | 22 | 23 | **Related Code** 24 | 25 | 26 | 27 | **Additional Context** 28 | 29 | 30 | 31 | **If the feature request is approved, would you be willing to submit a PR?** 32 | _(Help can be provided if you need assistance submitting a PR)_ 33 | 34 | - [ ] Yes 35 | - [ ] No 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03_CODEBASE_IMPROVEMENT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Codebase improvement 3 | about: Provide your feedback for the existing codebase. Suggest a better solution for algorithms, development tools, etc. 4 | title: "dev: " 5 | labels: "enhancement" 6 | assignees: "" 7 | --- 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/04_SUPPORT_QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Support Question 3 | about: Question on how to use this project 4 | title: "support: " 5 | labels: "question" 6 | assignees: "" 7 | --- 8 | 9 | # Support Question 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: false 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Pull Request type 4 | 5 | 6 | 7 | Please check the type of change your PR introduces: 8 | 9 | - [ ] Bugfix 10 | - [ ] Feature 11 | - [ ] Code style update (formatting, renaming) 12 | - [ ] Refactoring (no functional changes, no API changes) 13 | - [ ] Build-related changes 14 | - [ ] Documentation content changes 15 | - [ ] Other (please describe): 16 | 17 | ## What is the current behavior? 18 | 19 | 20 | 21 | Issue Number: N/A 22 | 23 | ## What is the new behavior? 24 | 25 | 26 | 27 | - 28 | - 29 | - 30 | 31 | ## Does this introduce a breaking change? 32 | 33 | - [ ] Yes 34 | - [ ] No 35 | 36 | 37 | 38 | ## Other information 39 | 40 | 41 | -------------------------------------------------------------------------------- /.github/actions/deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Install dependencies 2 | description: Installs dependencies 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Install pnpm 7 | uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # ratchet:pnpm/action-setup@v4 8 | with: 9 | version: 9.4.0 10 | 11 | - name: Setup Node 12 | uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # ratchet:actions/setup-node@v4 13 | with: 14 | node-version: 22 15 | cache: "pnpm" 16 | 17 | - name: Install dependencies 18 | shell: bash 19 | run: pnpm install 20 | -------------------------------------------------------------------------------- /.github/actions/docker/action.yml: -------------------------------------------------------------------------------- 1 | name: Build docker image 2 | description: Builds docker image and pushes to GHCR 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Set up QEMU 7 | uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # ratchet:docker/setup-qemu-action@v3 8 | 9 | - name: Set up Docker Buildx 10 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # ratchet:docker/setup-buildx-action@v3 11 | 12 | - name: Log in to the container registry 13 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # ratchet:docker/login-action@v3 14 | with: 15 | registry: ${{ env.REGISTRY }} 16 | username: ${{ github.actor }} 17 | password: ${{ env.GH_TOKEN }} 18 | 19 | - name: Extract metadata for Docker 20 | id: meta 21 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # ratchet:docker/metadata-action@v5 22 | with: 23 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 24 | 25 | - name: Build and push Docker image 26 | id: build-push 27 | uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # ratchet:docker/build-push-action@v6 28 | with: 29 | file: docker/Dockerfile 30 | platforms: linux/amd64,linux/arm64 31 | push: true 32 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 33 | labels: ${{ steps.meta.outputs.labels }} 34 | 35 | - name: Generate artifact attestation 36 | uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # ratchet:actions/attest-build-provenance@v2 37 | with: 38 | subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | subject-digest: ${{ steps.build-push.outputs.digest }} 40 | push-to-registry: true 41 | -------------------------------------------------------------------------------- /.github/actions/playwright/action.yml: -------------------------------------------------------------------------------- 1 | name: Run e2e tests 2 | description: Runs playwright tests 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Run Playwright tests 7 | shell: bash 8 | run: pnpm exec playwright test 9 | env: 10 | HOME: /root 11 | 12 | - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4 13 | if: always() 14 | with: 15 | name: playwright-report 16 | path: playwright-report/ 17 | retention-days: 30 18 | -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | description: Runs checks and tests 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: Lint 7 | shell: bash 8 | run: pnpm lint 9 | 10 | - name: Run tests 11 | shell: bash 12 | run: pnpm test:unit -- --run 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | groups: 13 | deps-prod: 14 | dependency-type: "production" 15 | deps-dev: 16 | dependency-type: "development" 17 | - package-ecosystem: "github-actions" 18 | directory: "/" 19 | schedule: 20 | interval: "monthly" 21 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | permissions: 3 | contents: read 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | on: 8 | pull_request: 9 | types: [opened, reopened] 10 | 11 | jobs: 12 | test: 13 | name: test 14 | timeout-minutes: 5 15 | permissions: 16 | contents: read 17 | runs-on: ubuntu-latest 18 | container: 19 | image: mcr.microsoft.com/playwright:v1.52.0@sha256:a021500a801bab0611049217ffad6b9697d827205c15babb86a53bc1a61c02d5 # ratchet:mcr.microsoft.com/playwright:v1.52.0 20 | steps: 21 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 22 | with: 23 | persist-credentials: false 24 | 25 | - uses: ./.github/actions/deps 26 | name: Install 27 | - uses: ./.github/actions/test 28 | name: Test 29 | - uses: ./.github/actions/playwright 30 | name: Test e2e 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | env: 7 | REGISTRY: ghcr.io 8 | IMAGE_NAME: ${{ github.repository }} 9 | 10 | jobs: 11 | test: 12 | name: test 13 | timeout-minutes: 5 14 | permissions: 15 | contents: read 16 | runs-on: ubuntu-latest 17 | container: 18 | image: mcr.microsoft.com/playwright:v1.52.0@sha256:a021500a801bab0611049217ffad6b9697d827205c15babb86a53bc1a61c02d5 # ratchet:mcr.microsoft.com/playwright:v1.52.0 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 21 | with: 22 | persist-credentials: false 23 | 24 | - uses: ./.github/actions/deps 25 | name: Install 26 | - uses: ./.github/actions/test 27 | name: Test 28 | - uses: ./.github/actions/playwright 29 | name: Test e2e 30 | 31 | release: 32 | name: release 33 | timeout-minutes: 10 34 | permissions: 35 | id-token: write 36 | attestations: write 37 | contents: write 38 | packages: write 39 | runs-on: ubuntu-latest 40 | needs: 41 | - test 42 | steps: 43 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # ratchet:actions/checkout@v4 44 | with: 45 | persist-credentials: false 46 | ref: main 47 | fetch-depth: 0 48 | 49 | - name: Semantic Release 50 | id: semrel 51 | uses: go-semantic-release/action@2e9dc4247a6004f8377781bef4cb9dad273a741f # ratchet:go-semantic-release/action@v1 52 | with: 53 | github-token: ${{ secrets.SEMANTIC_RELEASE_TOKEN }} 54 | update-file: package.json 55 | changelog-file: CHANGELOG.md 56 | prepend: true 57 | 58 | - name: Commit changelog & package.json updates 59 | if: steps.semrel.outputs.version != '' 60 | run: | 61 | git config user.name 'github-actions[bot]' 62 | git config user.email '41898282+github-actions[bot]@users.noreply.github.com' 63 | git add CHANGELOG.md package.json 64 | git commit -m "chore(release): ${{ steps.semrel.outputs.version }}" 65 | 66 | - uses: ./.github/actions/deps 67 | if: steps.semrel.outputs.version != '' 68 | name: Install 69 | 70 | - name: Build 71 | if: steps.semrel.outputs.version != '' 72 | env: 73 | NODE_ENV: "production" 74 | run: pnpm build 75 | 76 | - name: Deploy to Cloudflare Pages 77 | if: steps.semrel.outputs.version != '' 78 | uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # ratchet:cloudflare/wrangler-action@v3 79 | with: 80 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 81 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 82 | command: pages deploy build --project-name=guitos 83 | 84 | - uses: ./.github/actions/docker 85 | if: steps.semrel.outputs.version != '' 86 | name: Build docker image 87 | env: 88 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | 90 | - name: Compress bundle 91 | if: steps.semrel.outputs.version != '' 92 | run: mv build guitos && zip -r guitos-v${{ steps.semrel.outputs.version }}.zip guitos/* 93 | 94 | - name: Generate artifact attestation 95 | if: steps.semrel.outputs.version != '' 96 | uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # ratchet:actions/attest-build-provenance@v2 97 | with: 98 | subject-path: guitos-v${{ steps.semrel.outputs.version }}.zip 99 | 100 | - name: Upload bundle to GH release 101 | if: steps.semrel.outputs.version != '' 102 | run: | 103 | gh release upload v${{ steps.semrel.outputs.version }} guitos-v${{ steps.semrel.outputs.version }}.zip 104 | env: 105 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | 107 | - name: Push changelog & package.json updates 108 | if: steps.semrel.outputs.version != '' 109 | run: git push https://${{ secrets.SEMANTIC_RELEASE_TOKEN }}@github.com/${GITHUB_REPOSITORY}.git main 110 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /html 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | todo.md 17 | loc.txt 18 | .DS_Store 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | /test-results/ 28 | /playwright-report/ 29 | /blob-report/ 30 | /playwright/.cache/ 31 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | publish-branch=main 2 | save-prefix="" 3 | -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "map": true, 3 | "plugins": { 4 | "autoprefixer": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "DavidAnson.vscode-markdownlint", 4 | "biomejs.biome", 5 | "streetsidesoftware.code-spell-checker" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "editor.defaultFormatter": "biomejs.biome", 6 | "editor.formatOnSave": true, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | "search.exclude": { 9 | "pnpm-lock.yaml": true 10 | }, 11 | "npm.packageManager": "pnpm", 12 | "explorer.fileNesting.patterns": { 13 | "package.json": "pnpm-lock.yaml" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method with the owners of this repository before making a change. 4 | Please note we have a [code of conduct](docs/CODE_OF_CONDUCT.md), please follow it in all your interactions with the project. 5 | 6 | ## Development environment setup 7 | 8 | To set up a development environment, please follow these steps: 9 | 10 | 1. Clone the repo 11 | 12 | ```sh 13 | git clone https://github.com/rare-magma/guitos 14 | ``` 15 | 16 | 2. Install dependencies 17 | 18 | ```sh 19 | pnpm install 20 | ``` 21 | 22 | 3. Start the local web server 23 | 24 | ```sh 25 | pnpm start 26 | ``` 27 | 28 | 4. Open to view it in the browser. 29 | 30 | The page will reload if you make edits. 31 | You will also see any lint errors in the console. 32 | 33 | 5. Run all tests 34 | 35 | ```sh 36 | pnpm test 37 | ``` 38 | 39 | Launches the test runner in the interactive watch mode. 40 | 41 | 6. Run end to end tests 42 | 43 | ```sh 44 | pnpm test:e2e:ui 45 | ``` 46 | 47 | Launches the e2e test runner in the interactive mode. 48 | 49 | ## Issues and feature requests 50 | 51 | You've found a bug in the source code, a mistake in the documentation or maybe you'd like a new feature? You can help us by [submitting an issue on GitHub](https://github.com/rare-magma/guitos/issues). Before you create an issue, make sure to search the issue archive -- your issue may have already been addressed! 52 | 53 | Please try to create bug reports that are: 54 | 55 | - _Reproducible._ Include steps to reproduce the problem. 56 | - _Specific._ Include as much detail as possible: which version, what environment, etc. 57 | - _Unique._ Do not duplicate existing opened issues. 58 | - _Scoped to a Single Bug._ One bug per report. 59 | 60 | **Even better: Submit a pull request with a fix or new feature!** 61 | 62 | ### How to submit a Pull Request 63 | 64 | 1. Search our repository for open or closed 65 | [Pull Requests](https://github.com/rare-magma/guitos/pulls) 66 | that relate to your submission. You don't want to duplicate effort. 67 | 2. Fork the project 68 | 3. Create your feature branch (`git checkout -b feat/amazing_feature`) 69 | 4. Commit your changes (`git commit -m 'feat: add amazing_feature'`) guitos uses [conventional commits](https://www.conventionalcommits.org), so please follow the specification in your commit messages. 70 | 5. Push to the branch (`git push origin feat/amazing_feature`) 71 | 6. [Open a Pull Request](https://github.com/rare-magma/guitos/compare?expand=1) 72 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If there are any vulnerabilities in **guitos**, don't hesitate to _report them_. 6 | 7 | 1. Use any of the [private contact addresses](https://github.com/rare-magma/guitos#support). 8 | 2. Describe the vulnerability. 9 | 10 | If you have a fix, that is most welcome -- please attach or summarize it in your message! 11 | 12 | 3. We will evaluate the vulnerability and, if necessary, release a fix or mitigating steps to address it. We will contact you to let you know the outcome, and will credit you in the report. 13 | 14 | Please **do not disclose the vulnerability publicly** until a fix is released! 15 | 16 | 4. Once we have either a) published a fix, or b) declined to address the vulnerability for whatever reason, you are free to publicly disclose it. 17 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "formatWithErrors": false, 6 | "indentStyle": "space", 7 | "indentWidth": 2, 8 | "lineEnding": "lf", 9 | "lineWidth": 80, 10 | "attributePosition": "auto" 11 | }, 12 | "vcs": { 13 | "enabled": true, 14 | "clientKind": "git", 15 | "useIgnoreFile": true, 16 | "defaultBranch": "main" 17 | }, 18 | "organizeImports": { "enabled": true }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "a11y": { 24 | "all": true 25 | }, 26 | "complexity": { 27 | "noExcessiveCognitiveComplexity": "error" 28 | }, 29 | "correctness": { 30 | "noUndeclaredVariables": "error", 31 | "noUnusedImports": "error", 32 | "useArrayLiterals": "error", 33 | "useHookAtTopLevel": "error" 34 | }, 35 | "performance": { 36 | "all": true 37 | }, 38 | "security": { 39 | "all": true 40 | }, 41 | "style": { 42 | "noDefaultExport": "error", 43 | "noImplicitBoolean": "error", 44 | "noNegationElse": "error" 45 | }, 46 | "suspicious": { 47 | "noEmptyBlockStatements": "error", 48 | "useAwait": "error" 49 | } 50 | } 51 | }, 52 | "javascript": { 53 | "formatter": { 54 | "jsxQuoteStyle": "double", 55 | "quoteProperties": "asNeeded", 56 | "trailingCommas": "all", 57 | "semicolons": "always", 58 | "arrowParentheses": "always", 59 | "bracketSpacing": true, 60 | "bracketSameLine": false, 61 | "quoteStyle": "double", 62 | "attributePosition": "auto" 63 | } 64 | }, 65 | "overrides": [] 66 | } 67 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/library/node:lts-alpine AS build 2 | RUN npm install -g pnpm 3 | WORKDIR /app 4 | RUN chown -R node:node /app 5 | USER node 6 | COPY --chown=node:node package.json pnpm-lock.yaml ./ 7 | RUN pnpm install 8 | COPY --chown=node:node index.html vite.config.ts tsconfig.json .postcssrc.json .npmrc ./ 9 | COPY --chown=node:node public ./public 10 | COPY --chown=node:node src ./src 11 | RUN pnpm build && \ 12 | find ./build \ 13 | -type f \ 14 | -size +1500c \ 15 | ! -name "*.gz" \ 16 | ! -name "*.svg" \ 17 | ! -name "index.html" \ 18 | ! -name "robots.txt" \ 19 | | while read file; do gzip "$file"; done 20 | 21 | FROM docker.io/library/busybox:latest 22 | RUN adduser -D static 23 | USER static 24 | WORKDIR /home/static 25 | COPY --chown=static:static docker/httpd.conf . 26 | COPY --from=build --chown=static:static /app/build . 27 | EXPOSE 3000 28 | HEALTHCHECK --interval=30s --timeout=1s --start-period=5s --retries=3 CMD [ "wget", "--no-verbose", "--tries=1", "--spider", "127.0.0.1:3000" ] 29 | CMD ["busybox", "httpd", "-f", "-c", "httpd.conf", "-p", "3000"] 30 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | guitos: 3 | image: ghcr.io/rare-magma/guitos:latest 4 | container_name: guitos 5 | read_only: true 6 | init: true 7 | cap_drop: 8 | - ALL 9 | security_opt: 10 | - no-new-privileges:true 11 | deploy: 12 | resources: 13 | limits: 14 | cpus: "1" 15 | memory: 64m 16 | pids: 10 17 | ports: 18 | - "3000:3000" 19 | -------------------------------------------------------------------------------- /docker/httpd.conf: -------------------------------------------------------------------------------- 1 | E404:index.html 2 | -------------------------------------------------------------------------------- /docs/2023-06.csv: -------------------------------------------------------------------------------- 1 | type,name,value 2 | expense,rent,300 3 | expense,groceries,100 4 | expense,electricity,50 5 | expense,internet,30 6 | expense,gym,25 7 | expense,water,15 8 | expense,cellphone,20 9 | expense,coffee,10 10 | income,salary,1000 11 | income,2nd hand sale,50 12 | goal,goal,10 13 | reserves,reserves,1000 -------------------------------------------------------------------------------- /docs/2023-07.csv: -------------------------------------------------------------------------------- 1 | type,name,value 2 | expense,rent,300 3 | expense,groceries,100 4 | expense,electricity,50 5 | expense,internet,30 6 | expense,gym,25 7 | expense,water,15 8 | expense,cellphone,20 9 | expense,coffee,10 10 | expense,car rental,200 11 | income,salary,1000 12 | income,2nd hand sale,250 13 | goal,goal,10 14 | reserves,reserves,1125 -------------------------------------------------------------------------------- /docs/2023-08.csv: -------------------------------------------------------------------------------- 1 | type,name,value 2 | expense,rent,300 3 | expense,groceries,100 4 | expense,electricity,50 5 | expense,internet,30 6 | expense,gym,25 7 | expense,water,15 8 | expense,cellphone,20 9 | expense,coffee,10 10 | expense,car rental,200 11 | expense,bike rental,100 12 | income,salary,1000 13 | income,2nd hand sale,250 14 | income,2nd hand sale,250 15 | goal,goal,20 16 | reserves,reserves,1125 -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer using any of the [private contact addresses](https://github.com/rare-magma/guitos#support). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, available at 44 | 45 | For answers to common questions about this code of conduct, see 46 | -------------------------------------------------------------------------------- /docs/guitos-sample.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "20c9542a-6876-40a9-a3d7-f42f17ba8a0b", 4 | "name": "2023-06", 5 | "expenses": { 6 | "items": [ 7 | { "id": 1, "name": "rent", "value": 300 }, 8 | { "id": 2, "name": "groceries", "value": 100 }, 9 | { "id": 3, "name": "electricity", "value": 50 }, 10 | { "id": 4, "name": "internet", "value": 30 }, 11 | { "id": 5, "name": "gym", "value": 25 }, 12 | { "id": 6, "name": "water", "value": 15 }, 13 | { "id": 7, "name": "cellphone", "value": 20 }, 14 | { "id": 8, "name": "coffee", "value": 10 } 15 | ], 16 | "total": 550 17 | }, 18 | "incomes": { 19 | "items": [ 20 | { "id": 1, "name": "salary", "value": 1000 }, 21 | { "id": 2, "name": "2nd hand sale", "value": 50 } 22 | ], 23 | "total": 1050 24 | }, 25 | "stats": { 26 | "available": 500, 27 | "withGoal": 395, 28 | "saved": 105, 29 | "goal": 10, 30 | "reserves": 1000 31 | } 32 | }, 33 | { 34 | "id": "872115fd-48ed-4890-ad75-716ee9539943", 35 | "name": "2023-07", 36 | "expenses": { 37 | "items": [ 38 | { "id": 1, "name": "rent", "value": 300 }, 39 | { "id": 2, "name": "groceries", "value": 100 }, 40 | { "id": 3, "name": "electricity", "value": 50 }, 41 | { "id": 4, "name": "internet", "value": 30 }, 42 | { "id": 5, "name": "gym", "value": 25 }, 43 | { "id": 6, "name": "water", "value": 15 }, 44 | { "id": 7, "name": "cellphone", "value": 20 }, 45 | { "id": 8, "name": "coffee", "value": 10 }, 46 | { "id": 9, "name": "car rental", "value": 200 } 47 | ], 48 | "total": 750 49 | }, 50 | "incomes": { 51 | "items": [ 52 | { "id": 1, "name": "salary", "value": 1000 }, 53 | { "id": 2, "name": "2nd hand sale", "value": 250 } 54 | ], 55 | "total": 1250 56 | }, 57 | "stats": { 58 | "available": 500, 59 | "withGoal": 375, 60 | "saved": 125, 61 | "goal": 10, 62 | "reserves": 1125 63 | } 64 | }, 65 | { 66 | "id": "2896e048-3ac9-440b-847a-c0897197a444", 67 | "name": "2023-08", 68 | "expenses": { 69 | "items": [ 70 | { "id": 1, "name": "rent", "value": 300 }, 71 | { "id": 2, "name": "groceries", "value": 100 }, 72 | { "id": 3, "name": "electricity", "value": 50 }, 73 | { "id": 4, "name": "internet", "value": 30 }, 74 | { "id": 5, "name": "gym", "value": 25 }, 75 | { "id": 6, "name": "water", "value": 15 }, 76 | { "id": 7, "name": "cellphone", "value": 20 }, 77 | { "id": 8, "name": "coffee", "value": 10 }, 78 | { "id": 9, "name": "car rental", "value": 200 }, 79 | { "id": 10, "name": "bike rental", "value": 100 } 80 | ], 81 | "total": 850 82 | }, 83 | "incomes": { 84 | "items": [ 85 | { "id": 1, "name": "salary", "value": 1000 }, 86 | { "id": 2, "name": "2nd hand sale", "value": 250 }, 87 | { "id": 3, "name": "2nd hand sale", "value": 250 } 88 | ], 89 | "total": 1500 90 | }, 91 | "stats": { 92 | "available": 650, 93 | "withGoal": 350, 94 | "saved": 300, 95 | "goal": 20, 96 | "reserves": 1125 97 | } 98 | } 99 | ] 100 | -------------------------------------------------------------------------------- /docs/guitos-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "array", 4 | "items": { 5 | "type": "object", 6 | "properties": { 7 | "id": { 8 | "type": "string" 9 | }, 10 | "name": { 11 | "type": "string" 12 | }, 13 | "expenses": { 14 | "type": "object", 15 | "properties": { 16 | "items": { 17 | "type": "array", 18 | "items": { 19 | "type": "object", 20 | "properties": { 21 | "id": { 22 | "type": "number" 23 | }, 24 | "name": { 25 | "type": "string" 26 | }, 27 | "value": { 28 | "type": "number" 29 | } 30 | }, 31 | "required": ["id", "name", "value"] 32 | } 33 | }, 34 | "total": { 35 | "type": "number" 36 | } 37 | } 38 | }, 39 | "incomes": { 40 | "type": "object", 41 | "properties": { 42 | "items": { 43 | "type": "array", 44 | "items": { 45 | "type": "object", 46 | "properties": { 47 | "id": { 48 | "type": "number" 49 | }, 50 | "name": { 51 | "type": "string" 52 | }, 53 | "value": { 54 | "type": "number" 55 | } 56 | }, 57 | "required": ["id", "name", "value"] 58 | } 59 | }, 60 | "total": { 61 | "type": "number" 62 | } 63 | } 64 | }, 65 | "stats": { 66 | "type": "object", 67 | "properties": { 68 | "available": { 69 | "type": "number" 70 | }, 71 | "withGoal": { 72 | "type": "number" 73 | }, 74 | "saved": { 75 | "type": "number" 76 | }, 77 | "goal": { 78 | "type": "number" 79 | }, 80 | "reserves": { 81 | "type": "number" 82 | } 83 | } 84 | } 85 | }, 86 | "required": ["id", "name", "expenses", "incomes", "stats"] 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /docs/images/charts-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/charts-light.png -------------------------------------------------------------------------------- /docs/images/charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/charts.png -------------------------------------------------------------------------------- /docs/images/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/history.png -------------------------------------------------------------------------------- /docs/images/horizontal-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/horizontal-light.png -------------------------------------------------------------------------------- /docs/images/horizontal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/horizontal.png -------------------------------------------------------------------------------- /docs/images/initial-state-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/initial-state-light.png -------------------------------------------------------------------------------- /docs/images/initial-state-vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/initial-state-vertical.png -------------------------------------------------------------------------------- /docs/images/initial-state.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/initial-state.png -------------------------------------------------------------------------------- /docs/images/logo.svg: -------------------------------------------------------------------------------- 1 | 💸 -------------------------------------------------------------------------------- /docs/images/vertical-charts-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical-charts-light.png -------------------------------------------------------------------------------- /docs/images/vertical-charts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical-charts.png -------------------------------------------------------------------------------- /docs/images/vertical-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical-light.png -------------------------------------------------------------------------------- /docs/images/vertical-menu-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical-menu-light.png -------------------------------------------------------------------------------- /docs/images/vertical-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical-menu.png -------------------------------------------------------------------------------- /docs/images/vertical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rare-magma/guitos/2fed08661e2f6e83b0c33210866f73451666a5a7/docs/images/vertical.png -------------------------------------------------------------------------------- /e2e/accessibility.test.ts: -------------------------------------------------------------------------------- 1 | import { AxeBuilder } from "@axe-core/playwright"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | test("should not have any automatically detectable accessibility issues on landing page", async ({ 5 | page, 6 | }) => { 7 | await page.goto("/"); 8 | const accessibilityScanResults = await new AxeBuilder({ page }) 9 | .disableRules("color-contrast") 10 | .analyze(); 11 | 12 | expect(accessibilityScanResults.violations).toStrictEqual([]); 13 | }); 14 | 15 | test("should not have any automatically detectable accessibility issues", async ({ 16 | page, 17 | }) => { 18 | await page.goto("/"); 19 | await page.getByText("get started").click(); 20 | const accessibilityScanResults = await new AxeBuilder({ page }) 21 | .disableRules("color-contrast") 22 | .analyze(); 23 | 24 | expect(accessibilityScanResults.violations).toStrictEqual([]); 25 | }); 26 | -------------------------------------------------------------------------------- /e2e/settingsHappyPath.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import { expect, test } from "@playwright/test"; 3 | 4 | const rentRegex = /rent/; 5 | const csvRegex = /.csv/; 6 | const jsonRegex = /.json/; 7 | 8 | test("should complete the settings happy path", async ({ page, isMobile }) => { 9 | await page.goto("/"); 10 | 11 | // should show charts page 12 | await page.getByText("get started").click(); 13 | await page.locator("#Expenses-1-name").click(); 14 | await page.locator("#Expenses-1-name").fill("rent"); 15 | await page.locator("#Expenses-1-name").press("Tab"); 16 | await page.locator("#Expenses-1-value").fill("500.75"); 17 | 18 | await expect(page.locator("#Expenses-1-name")).toHaveValue("rent"); 19 | await expect(page.locator("#Expenses-1-value")).toHaveValue("$500.75"); 20 | 21 | await page.getByLabel("open charts view").click(); 22 | 23 | await expect(page.getByText("Revenue vs expenses")).toBeVisible(); 24 | // ensure y chart labels are visible 25 | await page.getByText("$600.00").hover(); 26 | await page.getByText("12%").hover(); 27 | await expect(page.getByText("Savings", { exact: true })).toBeVisible(); 28 | await expect(page.getByText("Reserves", { exact: true })).toBeVisible(); 29 | await expect(page.getByText("Available vs with goal")).toBeVisible(); 30 | await expect(page.getByText("Savings goal")).toBeVisible(); 31 | 32 | await page.getByPlaceholder("Filter...").click(); 33 | await page.getByPlaceholder("Filter...").fill("rent"); 34 | await page.getByLabel(rentRegex).click(); 35 | 36 | await page.getByText("strict match").click(); 37 | await expect(page.getByText("Expenses filtered by: rent")).toBeVisible(); 38 | 39 | await page.getByLabel("go back to budgets").click(); 40 | 41 | await expect(page.getByText("Statistics")).toBeVisible(); 42 | 43 | // should handle settings changes 44 | if (isMobile) { 45 | await page.getByLabel("Toggle navigation").click(); 46 | } 47 | await page.getByLabel("budget settings").click(); 48 | await page.getByLabel("select display currency").click(); 49 | await page.getByLabel("select display currency").fill("eur"); 50 | await page.getByLabel("EUR").click(); 51 | 52 | await expect(page.getByText("€0.00")).toBeVisible(); 53 | 54 | await page.getByLabel("import or export budget").click(); 55 | await expect(page.getByText("csv")).toBeVisible(); 56 | 57 | // should handle downloads 58 | const csvDownloadPromise = page.waitForEvent("download"); 59 | await page.getByLabel("export budget as csv").click(); 60 | const csvDownload = await csvDownloadPromise; 61 | expect(csvDownload.suggestedFilename()).toMatch(csvRegex); 62 | expect( 63 | (await fs.promises.stat(await csvDownload.path())).size, 64 | ).toBeGreaterThan(0); 65 | 66 | await page.getByLabel("import or export budget").click(); 67 | await expect(page.getByText("json")).toBeVisible(); 68 | 69 | const [jsonDownload] = await Promise.all([ 70 | page.waitForEvent("download"), 71 | page.getByLabel("export budget as json").click(), 72 | ]); 73 | 74 | const downloadError = await jsonDownload.failure(); 75 | if (downloadError !== null) { 76 | console.log("Error on download:", downloadError); 77 | throw new Error(downloadError); 78 | } 79 | 80 | expect(jsonDownload.suggestedFilename()).toMatch(jsonRegex); 81 | expect( 82 | (await fs.promises.stat(await jsonDownload.path())).size, 83 | ).toBeGreaterThan(0); 84 | 85 | // should handle import 86 | await expect(page.getByLabel("import or export budget")).toBeVisible(); 87 | await page.getByLabel("import or export budget").click(); 88 | await page 89 | .getByTestId("import-form-control") 90 | .setInputFiles("./docs/guitos-sample.json"); 91 | 92 | await page.getByLabel("go to newer budget").click({ force: true }); 93 | 94 | if (isMobile) { 95 | await page.getByLabel("Toggle navigation").click(); 96 | } 97 | 98 | await expect( 99 | page.getByRole("combobox", { name: "search in budgets" }), 100 | ).toBeVisible(); 101 | 102 | await expect(page.getByLabel("budget name")).toHaveValue("2023-06"); 103 | 104 | await page.close(); 105 | }); 106 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 13 | 14 | 15 | 16 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | guitos 36 | 37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "browserslist": { 3 | "production": "defaults", 4 | "development": [ 5 | "last 1 chrome version", 6 | "last 1 firefox version", 7 | "last 1 safari version" 8 | ] 9 | }, 10 | "dependencies": { 11 | "big.js": "7.0.1", 12 | "bootstrap": "5.3.6", 13 | "immer": "10.1.1", 14 | "localforage": "1.10.0", 15 | "motion": "12.15.0", 16 | "papaparse": "5.5.3", 17 | "react": "19.1.0", 18 | "react-bootstrap": "2.10.10", 19 | "react-bootstrap-typeahead": "6.4.1", 20 | "react-currency-input-field": "3.10.0", 21 | "react-dom": "19.1.0", 22 | "react-hotkeys-hook": "5.1.0", 23 | "react-icons": "5.5.0", 24 | "react-router": "7.6.1", 25 | "recharts": "2.15.3", 26 | "use-immer": "0.11.0", 27 | "use-undo": "1.1.1" 28 | }, 29 | "devDependencies": { 30 | "@axe-core/playwright": "4.10.1", 31 | "@biomejs/biome": "1.9.4", 32 | "@faker-js/faker": "9.8.0", 33 | "@playwright/test": "1.52.0", 34 | "@testing-library/dom": "10.4.0", 35 | "@testing-library/jest-dom": "6.6.3", 36 | "@testing-library/react": "16.3.0", 37 | "@testing-library/user-event": "14.6.1", 38 | "@types/big.js": "6.2.2", 39 | "@types/jest": "29.5.14", 40 | "@types/node": "22.15.29", 41 | "@types/papaparse": "5.3.16", 42 | "@types/react": "19.1.6", 43 | "@types/react-dom": "19.1.5", 44 | "@vitejs/plugin-react-swc": "3.10.0", 45 | "@vitest/coverage-v8": "3.2.0", 46 | "@vitest/ui": "3.2.0", 47 | "autoprefixer": "10.4.21", 48 | "console-fail-test": "0.5.0", 49 | "happy-dom": "17.5.6", 50 | "typescript": "5.8.3", 51 | "vite": "npm:rolldown-vite@6.3.14", 52 | "vite-plugin-pwa": "1.0.0", 53 | "vite-plugin-sri3": "1.0.6", 54 | "vitest": "3.2.0", 55 | "workbox-window": "7.3.0" 56 | }, 57 | "name": "guitos", 58 | "pnpm": { 59 | "onlyBuiltDependencies": [ 60 | "@biomejs/biome", 61 | "@swc/core", 62 | "esbuild" 63 | ] 64 | }, 65 | "private": true, 66 | "scripts": { 67 | "build": "vite build", 68 | "bundle": "pnpm dlx vite-bundle-visualizer", 69 | "coverage": "vitest run --coverage --silent --exclude 'e2e'", 70 | "coverage:ui": "vitest --ui --open --coverage --silent --exclude 'e2e'", 71 | "lint": "biome check --fix", 72 | "lint:packages": "pnpm dedupe", 73 | "schema": "pnpm dlx generate-schema ./docs/guitos-sample.json", 74 | "serve": "vite preview", 75 | "start": "vite", 76 | "test": "pnpm test:unit --run \u0026\u0026 pnpm test:e2e", 77 | "test:unit": "vitest --typecheck --exclude 'e2e'", 78 | "test:e2e": "playwright test --reporter=list --project chromium", 79 | "test:e2e:mobile": "playwright test --reporter=list --project 'Mobile Chrome'", 80 | "test:e2e:mobile:ui": "playwright test --ui --project 'Mobile Chrome'", 81 | "test:e2e:ui": "playwright test --ui --project chromium" 82 | }, 83 | "type": "module", 84 | "version": "1.5.3" 85 | } 86 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from "@playwright/test"; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | 13 | // biome-ignore lint/style/noDefaultExport: 14 | export default defineConfig({ 15 | timeout: 21 * 1000, // 21 seconds 16 | testDir: "./e2e", 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | // workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: "html", 27 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 28 | use: { 29 | /* Base URL to use in actions like `await page.goto('/')`. */ 30 | baseURL: "http://localhost:4173", 31 | 32 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 33 | trace: "on-first-retry", 34 | }, 35 | 36 | /* Configure projects for major browsers */ 37 | projects: [ 38 | { 39 | name: "chromium", 40 | use: { ...devices["Desktop Chrome"] }, 41 | }, 42 | 43 | // { 44 | // name: "firefox", 45 | // use: { ...devices["Desktop Firefox"] }, 46 | // }, 47 | 48 | // { 49 | // name: "webkit", 50 | // use: { ...devices["Desktop Safari"] }, 51 | // }, 52 | 53 | /* Test against mobile viewports. */ 54 | { 55 | name: "Mobile Chrome", 56 | use: { ...devices["Pixel 5"] }, 57 | }, 58 | // { 59 | // name: "Mobile Safari", 60 | // use: { ...devices["iPhone 12"] }, 61 | // }, 62 | ], 63 | 64 | /* Run your local dev server before starting the tests */ 65 | webServer: { 66 | command: "pnpm run build && pnpm run serve", 67 | url: "http://localhost:4173", 68 | reuseExistingServer: !process.env.CI, 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | Referrer-Policy: no-referrer 3 | Permissions-Policy: accelerometer=(), autoplay=(self), camera=(), display-capture=(self), fullscreen=(self), geolocation=(self), gyroscope=(self), magnetometer=(self), microphone=(), payment=(), picture-in-picture=(self), sync-xhr=(), usb=() 4 | Content-Security-Policy: default-src 'self'; img-src https://guitos.app https://www.w3.org data:; frame-ancestors 'none'; 5 | 6 | https://guitos.pages.dev/* 7 | X-Robots-Tag: noindex -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 💸 -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "guitos", 3 | "name": "guitos", 4 | "icons": [ 5 | { 6 | "src": "favicon.svg", 7 | "sizes": "any", 8 | "type": "image/svg+xml" 9 | } 10 | ], 11 | "id": "/", 12 | "start_url": "/", 13 | "display": "standalone", 14 | "theme_color": "#343746", 15 | "background_color": "#343746" 16 | } 17 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: var(--bgcolor); 3 | color: var(--textcolor); 4 | } 5 | 6 | a { 7 | color: var(--selection); 8 | text-decoration: none; 9 | } 10 | 11 | a:hover { 12 | border-bottom: 1px solid; 13 | color: var(--cyan); 14 | } 15 | 16 | .btn:focus-visible { 17 | background-color: var(--comment); 18 | box-shadow: none !important; 19 | color: var(--lightbgcolor); 20 | } 21 | 22 | .btn.show, 23 | .btn:hover { 24 | background-color: var(--comment); 25 | color: var(--lightbgcolor); 26 | } 27 | 28 | .btn-outline-secondary:disabled, 29 | .btn-outline-secondary { 30 | background-color: var(--bgcolor); 31 | border: 1px solid var(--lightbgcolor); 32 | border-radius: 0.375rem; 33 | } 34 | 35 | .btn-outline-secondary.toggle:disabled, 36 | .btn-outline-secondary.toggle { 37 | background-color: var(--lightbgcolor); 38 | border: 1px solid var(--lightbgcolor); 39 | border-radius: 0.375rem; 40 | } 41 | 42 | .btn-outline-info { 43 | background-color: var(--lightbgcolor); 44 | border: 1px solid var(--lightbgcolor); 45 | border-radius: 0.375rem; 46 | color: var(--cyan); 47 | } 48 | 49 | .btn-outline-info:focus-visible, 50 | .btn-outline-info:hover { 51 | box-shadow: none !important; 52 | background-color: var(--cyan); 53 | border: 1px solid var(--cyan); 54 | border-radius: 0.375rem; 55 | color: var(--lightbgcolor); 56 | } 57 | 58 | .btn-outline-primary { 59 | background-color: var(--lightbgcolor); 60 | border: 1px solid var(--lightbgcolor); 61 | border-radius: 0.375rem; 62 | color: var(--purple); 63 | } 64 | 65 | .btn-outline-primary:focus-visible, 66 | .btn-outline-primary:hover { 67 | background-color: var(--purple); 68 | border: 1px solid var(--purple); 69 | border-radius: 0.375rem; 70 | color: var(--lightbgcolor); 71 | } 72 | 73 | .btn-outline-success { 74 | background-color: var(--lightbgcolor); 75 | border: 1px solid var(--lightbgcolor); 76 | border-radius: 0.375rem; 77 | color: var(--highlight); 78 | } 79 | 80 | .btn-outline-success:focus-visible, 81 | .btn-outline-success:hover { 82 | background-color: var(--highlight); 83 | border: 1px solid var(--highlight); 84 | border-radius: 0.375rem; 85 | color: var(--lightbgcolor); 86 | } 87 | 88 | .card { 89 | border: 1px solid var(--lightbgcolor); 90 | background: var(--lightbgcolor); 91 | color: var(--textcolor); 92 | } 93 | 94 | .card-header { 95 | background: var(--lightbgcolor); 96 | border-bottom: 1px solid var(--lightbgcolor); 97 | } 98 | 99 | .card-footer { 100 | background: var(--lightbgcolor); 101 | border-top: 1px solid var(--lightbgcolor); 102 | } 103 | 104 | .fixed-width-font, 105 | .textarea { 106 | font-family: ui-monospace, "SF Mono", Menlo, Monaco, "Andale Mono", monospace; 107 | } 108 | 109 | .form-control, 110 | .input-group-text, 111 | input .input-group-text, 112 | input.form-control, 113 | input.text-end.form-control { 114 | background: var(--bgcolor); 115 | border: 1px solid var(--lightbgcolor); 116 | color: var(--textcolor); 117 | } 118 | 119 | input.form-control.budget-search, 120 | .form-control.budget-name { 121 | background: var(--lightbgcolor); 122 | border: 1px solid var(--lightbgcolor); 123 | color: var(--textcolor); 124 | } 125 | 126 | input.form-control.budget-search:focus, 127 | .form-control.budget-name:focus { 128 | background: var(--lightbgcolor); 129 | border: 1px solid var(--pink); 130 | box-shadow: 0 0 0; 131 | color: var(--textcolor); 132 | } 133 | 134 | .form-control:focus, 135 | input.text-end.form-control:focus { 136 | background: var(--bgcolor); 137 | border: 1px solid var(--pink); 138 | box-shadow: 0 0 0; 139 | color: var(--textcolor); 140 | } 141 | 142 | .form-control:disabled { 143 | background: var(--bgcolor); 144 | } 145 | 146 | /* Chrome, Safari, Edge, Opera */ 147 | input::-webkit-inner-spin-button, 148 | input::-webkit-outer-spin-button { 149 | appearance: none; 150 | margin: 0; 151 | } 152 | 153 | /* Firefox */ 154 | input[type="number"] { 155 | appearance: textfield; 156 | } 157 | 158 | .input-group-sm > .form-control { 159 | font-size: 1rem; 160 | } 161 | 162 | .popover { 163 | border: 1px solid var(--lightbgcolor); 164 | border-radius: 0.375rem; 165 | width: auto; 166 | } 167 | 168 | .popover, 169 | .popover-header { 170 | background: var(--comment); 171 | color: var(--textcolor); 172 | } 173 | 174 | .popover-header { 175 | border-bottom: 0 solid var(--comment); 176 | border: solid var(--comment); 177 | border-width: 0 0 1px; 178 | } 179 | 180 | .popover .popover-arrow::after, 181 | .popover .popover-arrow::before { 182 | border-bottom-color: var(--comment); 183 | border-top-color: var(--comment); 184 | } 185 | 186 | .pre, 187 | .textarea { 188 | background: var(--selection); 189 | border-radius: 0; 190 | font-size: 0.9em; 191 | margin: 0.8em 0 1em; 192 | max-height: 35vh; 193 | overflow: auto; 194 | padding: 0.6em 0.9em; 195 | } 196 | 197 | .straight-corners { 198 | border-radius: 0; 199 | } 200 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "bootstrap/dist/css/bootstrap.min.css"; 2 | import { Route, BrowserRouter as Router, Routes } from "react-router"; 3 | import "./App.css"; 4 | import "./colors.css"; 5 | import { BudgetProvider } from "./guitos/context/BudgetContext"; 6 | import { ConfigProvider } from "./guitos/context/ConfigContext"; 7 | import { GeneralProvider } from "./guitos/context/GeneralContext"; 8 | import { BudgetPage } from "./guitos/sections/Budget/BudgetPage"; 9 | 10 | export function App() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | } /> 18 | } /> 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/colors.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | * { 3 | --textcolor: #f8f8f2; 4 | --bgcolor: #21222c; 5 | --bs-body-color: #21222c; 6 | --lightbgcolor: #343746; 7 | --highlight: #50fa7b; 8 | --bs-success: #50fa7b; 9 | --red: #f55; 10 | --orange: #ffb86c; 11 | --yellow: #f1fa8c; 12 | --purple: #bd93f9; 13 | --cyan: #8be9fd; 14 | --pink: #ff79c6; 15 | --selection: #44475a; 16 | --comment: #6272a4; 17 | } 18 | } 19 | 20 | @media (prefers-color-scheme: light) { 21 | * { 22 | --bgcolor: #eaebf9; 23 | --lightbgcolor: #f8f8f2; 24 | --textcolor: #282a36; 25 | --highlight: #008504; 26 | --bs-success: #008504; 27 | --red: #d82f39; 28 | --orange: #a0651b; 29 | --yellow: #6c7908; 30 | --purple: #855fbf; 31 | --cyan: #007e90; 32 | --pink: #c13f8e; 33 | --selection: #c5c8de; 34 | --comment: #7e8ec2; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | declare const APP_VERSION: string; 2 | -------------------------------------------------------------------------------- /src/guitos/context/BudgetContext.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import { BudgetMother } from "../../guitos/domain/budget.mother"; 4 | import { BudgetProvider, useBudget } from "./BudgetContext"; 5 | 6 | function TestComponent() { 7 | const { budget, budgetList } = useBudget(); 8 | return ( 9 | <> 10 |

{JSON.stringify(budget)}

11 |

{JSON.stringify(budgetList)}

12 | 13 | ); 14 | } 15 | 16 | describe("BudgetProvider", () => { 17 | it("provides expected BudgetContext obj to child elements", () => { 18 | render( 19 | 20 | 21 | , 22 | ); 23 | expect(screen.getByLabelText("budget").textContent).toEqual( 24 | JSON.stringify(BudgetMother.testBudget()), 25 | ); 26 | expect(screen.getByLabelText("budgetList").textContent).toEqual( 27 | JSON.stringify(BudgetMother.testBudgetList()), 28 | ); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/guitos/context/BudgetContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type PropsWithChildren, 3 | createContext, 4 | useContext, 5 | useState, 6 | } from "react"; 7 | import useUndo from "use-undo"; 8 | import { Budget } from "../domain/budget"; 9 | import type { SearchOption } from "../sections/NavBar/NavBar"; 10 | import { useGeneralContext } from "./GeneralContext"; 11 | 12 | export interface BudgetContextInterface { 13 | budget: Budget | undefined; 14 | setBudget: (value: Budget | undefined, saveInHistory: boolean) => void; 15 | budgetList: Budget[] | undefined; 16 | setBudgetList: (value: Budget[] | undefined) => void; 17 | budgetNameList: SearchOption[] | undefined; 18 | setBudgetNameList: (value: SearchOption[] | undefined) => void; 19 | revenuePercentage: number; 20 | past: (Budget | undefined)[]; 21 | future: (Budget | undefined)[]; 22 | undo: () => void; 23 | redo: () => void; 24 | canUndo: boolean; 25 | canRedo: boolean; 26 | } 27 | 28 | const BudgetContext = createContext({ 29 | budget: undefined, 30 | setBudget: (_value: Budget | undefined, _saveInHistory: boolean) => { 31 | _value; 32 | _saveInHistory; 33 | }, 34 | budgetList: [], 35 | setBudgetList: (value: Budget[] | undefined) => value, 36 | budgetNameList: [], 37 | setBudgetNameList: (value: SearchOption[] | undefined) => value, 38 | revenuePercentage: 0, 39 | past: [undefined], 40 | future: [undefined], 41 | undo: () => { 42 | // undo 43 | }, 44 | redo: () => { 45 | // redo 46 | }, 47 | canUndo: false, 48 | canRedo: false, 49 | }); 50 | 51 | function useBudget() { 52 | const { 53 | budget, 54 | setBudget, 55 | budgetList, 56 | setBudgetList, 57 | budgetNameList, 58 | setBudgetNameList, 59 | revenuePercentage, 60 | past, 61 | future, 62 | undo, 63 | redo, 64 | canUndo, 65 | canRedo, 66 | } = useContext(BudgetContext); 67 | 68 | return { 69 | budget, 70 | setBudget, 71 | budgetList, 72 | setBudgetList, 73 | budgetNameList, 74 | setBudgetNameList, 75 | revenuePercentage, 76 | past, 77 | future, 78 | undo, 79 | redo, 80 | canUndo, 81 | canRedo, 82 | }; 83 | } 84 | 85 | function BudgetProvider({ children }: PropsWithChildren) { 86 | const [budgetList, setBudgetList] = useState([]); 87 | const [budgetNameList, setBudgetNameList] = useState< 88 | SearchOption[] | undefined 89 | >([]); 90 | const { setNeedReload } = useGeneralContext(); 91 | 92 | const [ 93 | budgetState, 94 | { 95 | set: setBudget, 96 | undo, 97 | redo, 98 | canUndo: undoPossible, 99 | canRedo: redoPossible, 100 | }, 101 | ] = useUndo(undefined, { useCheckpoints: true }); 102 | 103 | const { present: budget, past: pastState, future: futureState } = budgetState; 104 | const past = pastState.filter((b) => b?.id === budget?.id); 105 | const future = futureState.filter((b) => b?.id === budget?.id); 106 | 107 | const revenuePercentage = Budget.revenuePercentage(budget); 108 | 109 | const canReallyUndo = undoPossible && past[past.length - 1] !== undefined; 110 | const canReallyRedo = redoPossible && future[0] !== undefined; 111 | 112 | function handleUndo() { 113 | if (canReallyUndo) { 114 | setNeedReload(true); 115 | undo(); 116 | setNeedReload(false); 117 | } 118 | } 119 | 120 | function handleRedo() { 121 | if (canReallyRedo) { 122 | setNeedReload(true); 123 | redo(); 124 | setNeedReload(false); 125 | } 126 | } 127 | 128 | return ( 129 | 146 | {children} 147 | 148 | ); 149 | } 150 | 151 | export { BudgetProvider, useBudget }; 152 | -------------------------------------------------------------------------------- /src/guitos/context/ConfigContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type PropsWithChildren, 3 | createContext, 4 | useContext, 5 | useState, 6 | } from "react"; 7 | import type { IntlConfig } from "react-currency-input-field"; 8 | import { UserOptions } from "../domain/userOptions"; 9 | import { localForageOptionsRepository } from "../infrastructure/localForageOptionsRepository"; 10 | 11 | interface ConfigContextInterface { 12 | userOptions: UserOptions; 13 | intlConfig: IntlConfig; 14 | setUserOptions: (value: UserOptions) => void; 15 | } 16 | 17 | const optionsRepository = new localForageOptionsRepository(); 18 | 19 | const ConfigContext = createContext({ 20 | userOptions: new UserOptions( 21 | optionsRepository.getDefaultCurrencyCode(), 22 | optionsRepository.getUserLang(), 23 | ), 24 | intlConfig: UserOptions.toIntlConfig( 25 | new UserOptions( 26 | optionsRepository.getDefaultCurrencyCode(), 27 | optionsRepository.getUserLang(), 28 | ), 29 | ), 30 | setUserOptions: (value: UserOptions) => value, 31 | }); 32 | 33 | function useConfig() { 34 | const { userOptions, setUserOptions, intlConfig } = useContext(ConfigContext); 35 | 36 | return { userOptions, setUserOptions, intlConfig }; 37 | } 38 | 39 | function ConfigProvider({ children }: PropsWithChildren) { 40 | const [userOptions, setUserOptions] = useState( 41 | new UserOptions( 42 | optionsRepository.getDefaultCurrencyCode(), 43 | optionsRepository.getUserLang(), 44 | ), 45 | ); 46 | const intlConfig = UserOptions.toIntlConfig(userOptions); 47 | return ( 48 | 49 | {children} 50 | 51 | ); 52 | } 53 | 54 | export { ConfigProvider, useConfig }; 55 | -------------------------------------------------------------------------------- /src/guitos/context/GeneralContext.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type PropsWithChildren, 3 | createContext, 4 | useContext, 5 | useState, 6 | } from "react"; 7 | import { useImmer } from "use-immer"; 8 | import type { CsvError } from "../domain/csvError"; 9 | import type { JsonError } from "../domain/jsonError"; 10 | 11 | export interface BudgetNotification { 12 | show: boolean; 13 | id?: string; 14 | body?: string; 15 | showUndo?: boolean; 16 | } 17 | 18 | interface GeneralContextInterface { 19 | needReload: boolean; 20 | setNeedReload: (value: boolean) => void; 21 | loadingFromDB: boolean; 22 | setLoadingFromDB: (value: boolean) => void; 23 | error: string | null; 24 | setError: (value: string) => void; 25 | csvErrors: CsvError[]; 26 | setCsvErrors: (value: CsvError[]) => void; 27 | jsonErrors: JsonError[]; 28 | setJsonErrors: (value: JsonError[]) => void; 29 | showError: boolean; 30 | setShowError: (value: boolean) => void; 31 | notifications: BudgetNotification[]; 32 | setNotifications: (value: BudgetNotification[]) => void; 33 | } 34 | 35 | const GeneralContext = createContext({ 36 | needReload: true, 37 | setNeedReload: (value: boolean) => value, 38 | loadingFromDB: true, 39 | setLoadingFromDB: (value: boolean) => value, 40 | error: "", 41 | setError: (value: string) => value, 42 | csvErrors: [], 43 | setCsvErrors: (value: CsvError[]) => value, 44 | jsonErrors: [], 45 | setJsonErrors: (value: JsonError[]) => value, 46 | showError: false, 47 | setShowError: (value: boolean) => value, 48 | notifications: [], 49 | setNotifications: (value: BudgetNotification[]) => value, 50 | }); 51 | 52 | function useGeneralContext() { 53 | const { 54 | needReload, 55 | setNeedReload, 56 | loadingFromDB, 57 | setLoadingFromDB, 58 | error, 59 | setError, 60 | csvErrors, 61 | setCsvErrors, 62 | jsonErrors, 63 | setJsonErrors, 64 | showError, 65 | setShowError, 66 | notifications, 67 | setNotifications, 68 | } = useContext(GeneralContext); 69 | 70 | function handleError(e: unknown) { 71 | if (e instanceof Error) { 72 | setError(e.message); 73 | } 74 | setShowError(true); 75 | } 76 | 77 | return { 78 | needReload, 79 | setNeedReload, 80 | loadingFromDB, 81 | setLoadingFromDB, 82 | error, 83 | setError, 84 | csvErrors, 85 | setCsvErrors, 86 | jsonErrors, 87 | setJsonErrors, 88 | showError, 89 | setShowError, 90 | handleError, 91 | notifications, 92 | setNotifications, 93 | }; 94 | } 95 | 96 | function GeneralProvider({ children }: PropsWithChildren) { 97 | const [needReload, setNeedReload] = useState(true); 98 | const [loadingFromDB, setLoadingFromDB] = useState(true); 99 | const [error, setError] = useState(null); 100 | const [csvErrors, setCsvErrors] = useState([]); 101 | const [jsonErrors, setJsonErrors] = useState([]); 102 | const [showError, setShowError] = useState(false); 103 | const [notifications, setNotifications] = useImmer([]); 104 | 105 | return ( 106 | 124 | {children} 125 | 126 | ); 127 | } 128 | 129 | export { GeneralProvider, useGeneralContext }; 130 | -------------------------------------------------------------------------------- /src/guitos/domain/budget.mother.ts: -------------------------------------------------------------------------------- 1 | import { immerable } from "immer"; 2 | import { Budget } from "./budget"; 3 | import { BudgetItem } from "./budgetItem"; 4 | import { Uuid } from "./uuid"; 5 | 6 | // biome-ignore lint/complexity/noStaticOnlyClass: 7 | export class BudgetMother { 8 | static testBudget() { 9 | return { 10 | [immerable]: true, 11 | id: Uuid.random().value as unknown as Uuid, 12 | name: "2023-03", 13 | expenses: { 14 | items: [{ id: 1, name: "expense1", value: 10 }], 15 | total: 10, 16 | }, 17 | incomes: { 18 | items: [{ id: 2, name: "income1", value: 100 }], 19 | total: 100, 20 | }, 21 | stats: { 22 | available: 90, 23 | withGoal: 80, 24 | saved: 10, 25 | goal: 10, 26 | reserves: 200, 27 | }, 28 | }; 29 | } 30 | 31 | static testBudgetClone() { 32 | return Budget.clone(BudgetMother.testBudget() as Budget); 33 | } 34 | 35 | static testBudget2() { 36 | return { 37 | ...Budget.create(), 38 | id: Uuid.random().value as unknown as Uuid, 39 | name: "2023-04", 40 | expenses: { 41 | items: [{ id: 1, name: "name", value: 50 }], 42 | total: 50, 43 | }, 44 | incomes: { 45 | items: [{ id: 2, name: "name2", value: 200 }], 46 | total: 200, 47 | }, 48 | stats: { 49 | available: 150, 50 | withGoal: 130, 51 | saved: 20, 52 | goal: 35, 53 | reserves: 30, 54 | }, 55 | }; 56 | } 57 | 58 | static testBigBudget() { 59 | return { 60 | ...Budget.create(), 61 | name: "2023-03", 62 | expenses: { 63 | items: [ 64 | { id: 1, name: "name", value: 11378.64 }, 65 | { id: 4, name: "name2", value: 11378.64 }, 66 | ], 67 | total: 22757.28, 68 | }, 69 | incomes: { 70 | items: [ 71 | { id: 2, name: "name", value: 100.03 }, 72 | { id: 3, name: "name2", value: 342783.83 }, 73 | ], 74 | total: 342883.86, 75 | }, 76 | stats: { 77 | available: 320126.58, 78 | withGoal: 148684.65, 79 | saved: 171441.93, 80 | goal: 50, 81 | reserves: 200, 82 | }, 83 | }; 84 | } 85 | 86 | static testBudgetCsv() { 87 | return { 88 | ...Budget.create(), 89 | name: "2023-03", 90 | expenses: { 91 | items: [ 92 | new BudgetItem(0, "rent", 1000), 93 | new BudgetItem(1, "food", 200), 94 | ], 95 | total: 1200, 96 | }, 97 | incomes: { 98 | items: [ 99 | new BudgetItem(2, "salary", 2000), 100 | new BudgetItem(3, "sale", 100), 101 | ], 102 | total: 2100, 103 | }, 104 | stats: { 105 | available: 900, 106 | withGoal: 690, 107 | saved: 210, 108 | goal: 10, 109 | reserves: 0, 110 | }, 111 | }; 112 | } 113 | 114 | static testBudgetList() { 115 | return [ 116 | BudgetMother.testBudget(), 117 | BudgetMother.testBudget2(), 118 | BudgetMother.testBigBudget(), 119 | ]; 120 | } 121 | 122 | static testBudgetNameList() { 123 | return [ 124 | { 125 | id: BudgetMother.testBudget().id, 126 | item: "", 127 | name: BudgetMother.testBudget().name, 128 | }, 129 | { 130 | id: BudgetMother.testBudget2().id, 131 | item: "", 132 | name: BudgetMother.testBudget2().name, 133 | }, 134 | ]; 135 | } 136 | 137 | static testJSONErrorBudget() { 138 | return `{ 139 | id: "03123AAA5c2de4-00a4-403c-8f0e-f81339be9a4e", 140 | na2me: "2023-03", 141 | expens3es: { 142 | items: [{ id: "infinity", name: -1, value: "r" }], 143 | total: 10, 144 | }, 145 | stats: { 146 | available: 0, 147 | withGoal: 0, 148 | saved: 0, 149 | goal: 10, 150 | reserves: 0, 151 | }, 152 | }`; 153 | } 154 | 155 | static testCsv() { 156 | return `type,name,value 157 | expense,rent,1000.00 158 | expense,food,200.00 159 | income,salary,2000.00 160 | income,sale,100 161 | goal,goal,10 162 | reserves,reserves,0 163 | `; 164 | } 165 | 166 | static testCsvError() { 167 | return `type,name,value 168 | expe2nse,rent,1000.00 169 | expense,food,200.00,123,4 170 | incomae,salary,2000.00 171 | income,sale,100 172 | goal,123,goal 173 | goal,,goal,,, 174 | reservaes,reserves,0 175 | `; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/guitos/domain/budget.test.ts: -------------------------------------------------------------------------------- 1 | import Big from "big.js"; 2 | import Papa from "papaparse"; 3 | import { describe, expect, test } from "vitest"; 4 | import { Budget } from "./budget"; 5 | import { BudgetMother } from "./budget.mother"; 6 | import { BudgetItem } from "./budgetItem"; 7 | import { BudgetItemsMother } from "./budgetItem.mother"; 8 | 9 | describe("Budget", () => { 10 | test("clone", () => { 11 | const budget = BudgetMother.testBudget(); 12 | const clonedBudget = BudgetMother.testBudgetClone(); 13 | expect(clonedBudget).not.toBe(budget); 14 | expect(clonedBudget.name).toBe(`${budget.name}-clone`); 15 | }); 16 | 17 | test("itemsTotal", () => { 18 | expect( 19 | Budget.itemsTotal([ 20 | BudgetItemsMother.itemForm1(), 21 | BudgetItemsMother.itemForm2(), 22 | ]), 23 | ).toEqual(Big(110)); 24 | expect(Budget.itemsTotal([])).toEqual(Big(0)); 25 | }); 26 | 27 | test("percentage", () => { 28 | expect( 29 | BudgetItem.percentage( 30 | BudgetItemsMother.itemForm1().value, 31 | BudgetMother.testBudget().incomes.total, 32 | ), 33 | ).eq(10); 34 | expect( 35 | BudgetItem.percentage( 36 | BudgetItemsMother.itemForm2().value, 37 | BudgetMother.testBudget().incomes.total, 38 | ), 39 | ).eq(100); 40 | expect( 41 | BudgetItem.percentage( 42 | BudgetItemsMother.itemForm1().value, 43 | BudgetMother.testBudget().expenses.total, 44 | ), 45 | ).eq(100); 46 | expect( 47 | BudgetItem.percentage( 48 | BudgetItemsMother.itemForm2().value, 49 | BudgetMother.testBudget().expenses.total, 50 | ), 51 | ).eq(1000); 52 | expect(BudgetItem.percentage(0, 0)).eq(0); 53 | expect( 54 | BudgetItem.percentage(0, BudgetMother.testBudget().incomes.total), 55 | ).eq(0); 56 | expect( 57 | BudgetItem.percentage(0, BudgetMother.testBudget().expenses.total), 58 | ).eq(0); 59 | }); 60 | 61 | test("available", () => { 62 | expect(Budget.available(BudgetMother.testBudget() as Budget)).toEqual( 63 | Big(90), 64 | ); 65 | expect(Budget.available(undefined)).toEqual(Big(0)); 66 | }); 67 | 68 | test("availableWithGoal", () => { 69 | expect(Budget.availableWithGoal(BudgetMother.testBudget() as Budget)).eq( 70 | 80, 71 | ); 72 | }); 73 | 74 | test("saved", () => { 75 | expect(Budget.saved(BudgetMother.testBudget() as Budget)).eq(10); 76 | }); 77 | 78 | test("automaticGoal", () => { 79 | expect(Budget.automaticGoal(BudgetMother.testBigBudget())).eq(93.36298); 80 | }); 81 | 82 | test("fromCsv", () => { 83 | const csvObject = Papa.parse(BudgetMother.testCsv() as string, { 84 | header: true, 85 | skipEmptyLines: "greedy", 86 | }); 87 | expect(Budget.fromCsv(csvObject.data as string[], "2023-03")).toEqual( 88 | BudgetMother.testBudgetCsv(), 89 | ); 90 | }); 91 | 92 | test("toCsv", () => { 93 | expect(Budget.toCsv(BudgetMother.testBigBudget())).eq(`type,name,value 94 | expense,name,11378.64 95 | expense,name2,11378.64 96 | income,name,100.03 97 | income,name2,342783.83 98 | goal,goal,50 99 | reserves,reserves,200`); 100 | }); 101 | 102 | test("revenuePercentage", () => { 103 | expect(Budget.revenuePercentage(BudgetMother.testBudget() as Budget)).eq( 104 | 10, 105 | ); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/guitos/domain/budgetItem.mother.ts: -------------------------------------------------------------------------------- 1 | import { BudgetItem } from "./budgetItem"; 2 | import { ObjectMother } from "./objectMother.mother"; 3 | 4 | // biome-ignore lint/complexity/noStaticOnlyClass: 5 | export class BudgetItemsMother { 6 | static budgetItems(): BudgetItem[] { 7 | const list = [ 8 | new BudgetItem( 9 | ObjectMother.positiveNumber(), 10 | ObjectMother.word(), 11 | ObjectMother.zeroOrPositiveNumber(), 12 | ), 13 | new BudgetItem( 14 | ObjectMother.positiveNumber(), 15 | ObjectMother.word(), 16 | ObjectMother.zeroOrPositiveNumber(), 17 | ), 18 | new BudgetItem( 19 | ObjectMother.positiveNumber(), 20 | ObjectMother.word(), 21 | ObjectMother.zeroOrPositiveNumber(), 22 | ), 23 | ]; 24 | 25 | return ObjectMother.randomElementsFromList(list); 26 | } 27 | 28 | static itemForm1() { 29 | return new BudgetItem(1, "name1", 10); 30 | } 31 | 32 | static itemForm2() { 33 | return new BudgetItem(2, "name2", 100); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/guitos/domain/budgetItem.ts: -------------------------------------------------------------------------------- 1 | import Big from "big.js"; 2 | import { roundBig } from "../../utils"; 3 | 4 | export class BudgetItem { 5 | id: number; 6 | name: string; 7 | value: number; 8 | 9 | constructor(id: number, name: string, value: number) { 10 | this.id = id; 11 | this.name = name; 12 | this.value = value; 13 | } 14 | 15 | static create(): BudgetItem { 16 | return new BudgetItem(1, "", 0); 17 | } 18 | 19 | static percentage(itemValue: number, revenueTotal: number): number { 20 | if (!itemValue) return 0; 21 | const canRoundNumbers = 22 | !Number.isNaN(revenueTotal) && 23 | revenueTotal > 0 && 24 | !Number.isNaN(itemValue); 25 | if (!canRoundNumbers) { 26 | return 0; 27 | } 28 | const percentageOfTotal = Big(itemValue).mul(100).div(revenueTotal); 29 | return roundBig(percentageOfTotal, percentageOfTotal.gte(1) ? 0 : 1); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/guitos/domain/budgetRepository.ts: -------------------------------------------------------------------------------- 1 | import type { Budget } from "./budget"; 2 | import type { Uuid } from "./uuid"; 3 | 4 | export interface BudgetRepository { 5 | get(id: Uuid): Promise; 6 | getAll(): Promise; 7 | update(id: Uuid, newBudget: Budget): Promise; 8 | delete(id: Uuid): Promise; 9 | } 10 | -------------------------------------------------------------------------------- /src/guitos/domain/calcHistRepository.ts: -------------------------------------------------------------------------------- 1 | import type { CalculationHistoryItem } from "./calculationHistoryItem"; 2 | 3 | export interface CalcHistRepository { 4 | get(id: string): Promise; 5 | getAll(): Promise; 6 | update(id: string, newCalcHist: CalculationHistoryItem[]): Promise; 7 | delete(id: string): Promise; 8 | } 9 | -------------------------------------------------------------------------------- /src/guitos/domain/calculationHistoryItem.mother.ts: -------------------------------------------------------------------------------- 1 | import { BudgetMother } from "./budget.mother"; 2 | import { BudgetItemsMother } from "./budgetItem.mother"; 3 | 4 | // biome-ignore lint/complexity/noStaticOnlyClass: 5 | export class CalculationHistoryItemMother { 6 | static testCalcHist() { 7 | return [ 8 | { 9 | id: `${BudgetMother.testBudget().id}-Expenses-1`, 10 | itemForm: BudgetItemsMother.itemForm1(), 11 | changeValue: 123, 12 | operation: "add", 13 | }, 14 | { 15 | id: `${BudgetMother.testBudget().id}-Expenses-1`, 16 | itemForm: { 17 | id: 1, 18 | name: BudgetItemsMother.itemForm1().name, 19 | value: 133, 20 | }, 21 | changeValue: 3, 22 | operation: "add", 23 | }, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/guitos/domain/calculationHistoryItem.ts: -------------------------------------------------------------------------------- 1 | import { immerable } from "immer"; 2 | import type { BudgetItem } from "./budgetItem"; 3 | import { Uuid } from "./uuid"; 4 | 5 | export type ItemOperation = 6 | | "name" 7 | | "value" 8 | | "add" 9 | | "subtract" 10 | | "multiply" 11 | | "divide"; 12 | 13 | export class CalculationHistoryItem { 14 | id: string; 15 | itemForm: BudgetItem; 16 | changeValue: number; 17 | operation: ItemOperation; 18 | 19 | [immerable] = true; 20 | 21 | constructor( 22 | id: string, 23 | itemForm: BudgetItem, 24 | changeValue: number, 25 | operation: ItemOperation, 26 | ) { 27 | this.id = id; 28 | this.itemForm = itemForm; 29 | this.changeValue = changeValue; 30 | this.operation = operation; 31 | } 32 | 33 | static create( 34 | itemForm: BudgetItem, 35 | changeValue: number, 36 | operation: ItemOperation, 37 | ): CalculationHistoryItem { 38 | const newId = `${Uuid.random()}-${itemForm.id}`; 39 | const newCalcHist = new CalculationHistoryItem( 40 | newId, 41 | itemForm, 42 | changeValue, 43 | operation, 44 | ); 45 | 46 | return newCalcHist; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/guitos/domain/csvError.mother.ts: -------------------------------------------------------------------------------- 1 | import type { CsvError } from "./csvError"; 2 | 3 | // biome-ignore lint/complexity/noStaticOnlyClass: 4 | export class CsvErrorMother { 5 | static error(): CsvError { 6 | return { 7 | errors: [ 8 | { 9 | type: "FieldMismatch", 10 | code: "TooFewFields", 11 | message: "Line 0: Too few fields: expected 3 fields but parsed 2", 12 | row: 0, 13 | }, 14 | ], 15 | file: "123.csv", 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/guitos/domain/csvError.ts: -------------------------------------------------------------------------------- 1 | import type { ParseError } from "papaparse"; 2 | 3 | export class CsvError { 4 | errors: ParseError[]; 5 | file: string; 6 | 7 | constructor(errors: ParseError[], file: string) { 8 | this.errors = errors; 9 | this.file = file; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/guitos/domain/csvItem.ts: -------------------------------------------------------------------------------- 1 | import type Big from "big.js"; 2 | 3 | export type CsvType = "expense" | "income" | "goal" | "reserves"; 4 | 5 | export class CsvItem { 6 | type: CsvType; 7 | name: string; 8 | value: Big; 9 | 10 | constructor(type: CsvType, name: string, value: Big) { 11 | this.type = type; 12 | this.name = name; 13 | this.value = value; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/guitos/domain/expenses.ts: -------------------------------------------------------------------------------- 1 | import type { BudgetItem } from "./budgetItem"; 2 | 3 | export class Expenses { 4 | items: BudgetItem[]; 5 | total: number; 6 | 7 | constructor(items: BudgetItem[], total: number) { 8 | this.items = items; 9 | this.total = total; 10 | } 11 | 12 | static create(): Expenses { 13 | return new Expenses([], 0); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/guitos/domain/incomes.ts: -------------------------------------------------------------------------------- 1 | import type { BudgetItem } from "./budgetItem"; 2 | 3 | export class Incomes { 4 | items: BudgetItem[]; 5 | total: number; 6 | 7 | constructor(items: BudgetItem[], total: number) { 8 | this.items = items; 9 | this.total = total; 10 | } 11 | 12 | static create(): Incomes { 13 | return new Incomes([], 0); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/guitos/domain/jsonError.mother.ts: -------------------------------------------------------------------------------- 1 | import type { JsonError } from "./jsonError"; 2 | 3 | // biome-ignore lint/complexity/noStaticOnlyClass: 4 | export class JsonErrorMother { 5 | static error(): JsonError { 6 | return { 7 | errors: 8 | "SyntaxError: Expected ',' or '}' after property value in JSON at position 209", 9 | file: "123.json", 10 | }; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/guitos/domain/jsonError.ts: -------------------------------------------------------------------------------- 1 | export class JsonError { 2 | errors: string; 3 | file: string; 4 | 5 | constructor(errors: string, file: string) { 6 | this.errors = errors; 7 | this.file = file; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/guitos/domain/objectMother.mother.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { Uuid } from "./uuid"; 3 | 4 | // biome-ignore lint/complexity/noStaticOnlyClass: 5 | export class ObjectMother { 6 | static uuid(): Uuid { 7 | return new Uuid(faker.string.uuid()); 8 | } 9 | 10 | static randomNumber(): number { 11 | return faker.number.int({ min: Number.MIN_SAFE_INTEGER }); 12 | } 13 | 14 | static indexNumber(max: number): number { 15 | return faker.number.int({ min: 0, max }); 16 | } 17 | 18 | static word(): string { 19 | return faker.lorem.word(); 20 | } 21 | 22 | static words(): string { 23 | return faker.lorem.words(); 24 | } 25 | 26 | static coin(): boolean { 27 | return faker.datatype.boolean(); 28 | } 29 | 30 | static positiveNumber(max?: number): number { 31 | return faker.number.int({ min: 1, max }); 32 | } 33 | 34 | static zeroOrPositiveNumber(max?: number): number { 35 | return faker.number.int({ min: 0, max }); 36 | } 37 | 38 | static text(): string { 39 | return faker.lorem.paragraph(); 40 | } 41 | 42 | static recentDate(): Date { 43 | return faker.date.recent(); 44 | } 45 | 46 | static randomElementsFromList(list: T[]): T[] { 47 | return faker.helpers.arrayElements(list); 48 | } 49 | 50 | static currencyCode(): string { 51 | return faker.finance.currencyCode(); 52 | } 53 | 54 | static randomAlpha(): string { 55 | return faker.string.alpha(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/guitos/domain/stats.mother.ts: -------------------------------------------------------------------------------- 1 | import { ObjectMother } from "./objectMother.mother"; 2 | import { Stats } from "./stats"; 3 | 4 | // biome-ignore lint/complexity/noStaticOnlyClass: 5 | export class StatsMother { 6 | static budgetStats(): Stats { 7 | return new Stats( 8 | ObjectMother.randomNumber(), 9 | ObjectMother.randomNumber(), 10 | ObjectMother.randomNumber(), 11 | ObjectMother.randomNumber(), 12 | ObjectMother.randomNumber(), 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/guitos/domain/stats.ts: -------------------------------------------------------------------------------- 1 | export class Stats { 2 | available: number; 3 | withGoal: number; 4 | saved: number; 5 | goal: number; 6 | reserves: number; 7 | 8 | constructor( 9 | available: number, 10 | withGoal: number, 11 | saved: number, 12 | goal: number, 13 | reserves: number, 14 | ) { 15 | this.available = available; 16 | this.withGoal = withGoal; 17 | this.saved = saved; 18 | this.goal = goal; 19 | this.reserves = reserves; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/guitos/domain/userOptions.mother.ts: -------------------------------------------------------------------------------- 1 | import { ObjectMother } from "./objectMother.mother"; 2 | import { UserOptions } from "./userOptions"; 3 | 4 | // biome-ignore lint/complexity/noStaticOnlyClass: 5 | export class UserOptionsMother { 6 | static default(): UserOptions { 7 | return new UserOptions("USD", navigator.language); 8 | } 9 | 10 | static random(): UserOptions { 11 | const currencyCode = ObjectMother.currencyCode(); 12 | return new UserOptions(currencyCode, navigator.language); 13 | } 14 | 15 | static invalid(): UserOptions { 16 | const currencyCode = ObjectMother.randomAlpha(); 17 | return new UserOptions(currencyCode, navigator.language); 18 | } 19 | 20 | static spanish(): UserOptions { 21 | return new UserOptions("EUR", "es-ES"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/guitos/domain/userOptions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | import { UserOptionsMother } from "./userOptions.mother"; 3 | 4 | describe("Options", () => { 5 | it("should throw an error if the currency code is invalid", () => { 6 | expect(() => { 7 | UserOptionsMother.invalid(); 8 | }).toThrowError(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/guitos/domain/userOptions.ts: -------------------------------------------------------------------------------- 1 | import type { IntlConfig } from "react-currency-input-field"; 2 | import { currenciesList } from "../../lists/currenciesList"; 3 | 4 | export class UserOptions { 5 | currencyCode: string; 6 | locale: string; 7 | static CURRENCY_CODE = "currencyCode"; 8 | static LOCALE = "locale"; 9 | 10 | constructor(currencyCode: string, locale: string) { 11 | this.currencyCode = currencyCode; 12 | this.locale = locale; 13 | 14 | this.ensureIsValidCode(currencyCode); 15 | } 16 | 17 | private ensureIsValidCode(code: string): void { 18 | if (!currenciesList.includes(code)) { 19 | throw new Error( 20 | `<${this.constructor.name}> does not allow the currency code <${code}>`, 21 | ); 22 | } 23 | } 24 | 25 | static toIntlConfig(userOptions: UserOptions): IntlConfig { 26 | return { 27 | locale: userOptions.locale, 28 | currency: userOptions.currencyCode, 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/guitos/domain/userOptionsRepository.ts: -------------------------------------------------------------------------------- 1 | import type { UserOptions } from "./userOptions"; 2 | 3 | export interface UserOptionsRepository { 4 | getCurrencyCode(): Promise; 5 | saveCurrencyCode(options: UserOptions): Promise; 6 | getLocale(): Promise; 7 | saveLocale(Options: UserOptions): Promise; 8 | getUserLang(): string; 9 | getCountryCode(locale: string): string; 10 | getDefaultCurrencyCode(): string; 11 | } 12 | -------------------------------------------------------------------------------- /src/guitos/domain/uuid.ts: -------------------------------------------------------------------------------- 1 | const uuidRegex = 2 | /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; 3 | 4 | export class Uuid { 5 | readonly value: string; 6 | 7 | constructor(value: string) { 8 | this.value = value; 9 | this.ensureIsValidUuid(value); 10 | } 11 | 12 | toString(): string { 13 | return this.value; 14 | } 15 | 16 | static random(): Uuid { 17 | if (!window.isSecureContext) { 18 | throw new Error( 19 | " is not available in a non-secure context", 20 | ); 21 | } 22 | return new Uuid(crypto.randomUUID()); 23 | } 24 | 25 | private ensureIsValidUuid(id: string): void { 26 | if (!uuidRegex.test(id)) { 27 | throw new Error( 28 | `<${this.constructor.name}> does not allow the value <${id}>`, 29 | ); 30 | } 31 | if (!window.isSecureContext) { 32 | throw new Error( 33 | `<${this.constructor.name}> is not available in a non-secure context`, 34 | ); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/guitos/hooks/useMove.ts: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router"; 2 | import { saveLastOpenedBudget } from "../../utils"; 3 | import { useBudget } from "../context/BudgetContext"; 4 | import type { Budget } from "../domain/budget"; 5 | import type { SearchOption } from "../sections/NavBar/NavBar"; 6 | 7 | export function useMove() { 8 | const { budget, setBudget, budgetList } = useBudget(); 9 | const navigate = useNavigate(); 10 | 11 | function select(selectedBudget: SearchOption[] | undefined) { 12 | if (selectedBudget && budgetList) { 13 | const filteredList = budgetList.filter( 14 | (item: Budget) => item.id === selectedBudget[0].id, 15 | ); 16 | filteredList[0] && setBudget(filteredList[0], false); 17 | 18 | setTimeout(() => { 19 | if (selectedBudget[0].item && selectedBudget[0].item.length > 0) { 20 | const element = document.querySelector( 21 | `input[value="${selectedBudget[0].item}"]:not([class="rbt-input-hint"]):not([role="combobox"])`, 22 | ); 23 | if (element !== null) { 24 | (element as HTMLElement).focus(); 25 | } 26 | } 27 | }, 100); 28 | saveLastOpenedBudget(selectedBudget[0].name, navigate); 29 | } 30 | } 31 | 32 | function handleGo(step: number, limit: number) { 33 | const sortedList = budgetList?.sort((a, b) => a.name.localeCompare(b.name)); 34 | if (budget) { 35 | const index = sortedList?.findIndex((b) => b.name.includes(budget.name)); 36 | if (index !== limit && sortedList) { 37 | select([sortedList[(index ?? 0) + step] as unknown as SearchOption]); 38 | } 39 | } 40 | } 41 | 42 | function goHome() { 43 | if (budget) { 44 | const name = new Date().toISOString(); 45 | const index = budgetList?.findIndex((b) => 46 | b.name.includes(name.slice(0, 7)), 47 | ); 48 | const isSelectable = index !== undefined && index !== -1 && budgetList; 49 | 50 | if (isSelectable) { 51 | select([budgetList[index] as unknown as SearchOption]); 52 | } 53 | } 54 | } 55 | 56 | function goBack() { 57 | budgetList && handleGo(-1, 0); 58 | } 59 | 60 | function goForward() { 61 | budgetList && handleGo(1, budgetList.length - 1); 62 | } 63 | 64 | function checkCanGo(position: number): boolean { 65 | const sortedList = budgetList?.sort((a, b) => a.name.localeCompare(b.name)); 66 | if (!budget) { 67 | return false; 68 | } 69 | const index = sortedList?.findIndex((b) => b.name.includes(budget.name)); 70 | if (index !== position) { 71 | return true; 72 | } 73 | return false; 74 | } 75 | 76 | const canGoBack = checkCanGo(0); 77 | const canGoForward = budgetList && checkCanGo(budgetList?.length - 1); 78 | 79 | return { 80 | select, 81 | goBack, 82 | goForward, 83 | canGoBack, 84 | canGoForward, 85 | goHome, 86 | }; 87 | } 88 | -------------------------------------------------------------------------------- /src/guitos/hooks/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useState } from "react"; 2 | 3 | export function useWindowSize() { 4 | const [size, setSize] = useState({ 5 | width: 0, 6 | height: 0, 7 | }); 8 | 9 | useLayoutEffect(() => { 10 | const handleResize = () => { 11 | setSize({ 12 | width: window.innerWidth, 13 | height: window.innerHeight, 14 | }); 15 | }; 16 | 17 | handleResize(); 18 | window.addEventListener("resize", handleResize); 19 | 20 | return () => { 21 | window.removeEventListener("resize", handleResize); 22 | }; 23 | }, []); 24 | 25 | return size; 26 | } 27 | -------------------------------------------------------------------------------- /src/guitos/infrastructure/localForageBudgetRepository.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage"; 2 | import { Budget } from "../domain/budget"; 3 | import type { BudgetRepository } from "../domain/budgetRepository"; 4 | import type { Uuid } from "../domain/uuid"; 5 | 6 | export class localForageBudgetRepository implements BudgetRepository { 7 | private readonly budgetsDB; 8 | 9 | constructor() { 10 | this.budgetsDB = localforage.createInstance({ 11 | name: "guitos", 12 | storeName: "budgets", 13 | }); 14 | } 15 | 16 | async get(id: Uuid): Promise { 17 | try { 18 | const budget = await this.budgetsDB.getItem(id.toString()); 19 | if (!budget) throw new Error(); 20 | return budget; 21 | } catch (e) { 22 | throw new Error((e as Error).message); 23 | } 24 | } 25 | 26 | async getAll(): Promise { 27 | try { 28 | const list: Budget[] = []; 29 | for (const item of await this.budgetsDB.keys()) { 30 | if (item) { 31 | const budget = await this.budgetsDB.getItem(item); 32 | if (budget) { 33 | list.push(budget); 34 | } 35 | } 36 | } 37 | return list; 38 | } catch (e) { 39 | throw new Error((e as Error).message); 40 | } 41 | } 42 | 43 | async update(id: Uuid, newBudget: Budget): Promise { 44 | try { 45 | await this.budgetsDB.setItem( 46 | id.toString(), 47 | Budget.toSafeFormat(newBudget), 48 | ); 49 | return true; 50 | } catch { 51 | return false; 52 | } 53 | } 54 | 55 | async delete(id: Uuid): Promise { 56 | try { 57 | await this.budgetsDB.removeItem(id.toString()); 58 | return true; 59 | } catch { 60 | return false; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/guitos/infrastructure/localForageCalcHistRepository.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage"; 2 | import type { CalcHistRepository } from "../domain/calcHistRepository"; 3 | import type { CalculationHistoryItem } from "../domain/calculationHistoryItem"; 4 | 5 | export class localForageCalcHistRepository implements CalcHistRepository { 6 | private readonly calcHistDB; 7 | 8 | constructor() { 9 | this.calcHistDB = localforage.createInstance({ 10 | name: "guitos", 11 | storeName: "calcHistDB", 12 | }); 13 | } 14 | async get(id: string): Promise { 15 | try { 16 | return await this.calcHistDB.getItem(id); 17 | } catch { 18 | return null; 19 | } 20 | } 21 | 22 | async getAll(): Promise { 23 | try { 24 | const list: CalculationHistoryItem[][] = []; 25 | for (const item of await this.calcHistDB.keys()) { 26 | if (item) { 27 | const calcHist = 28 | await this.calcHistDB.getItem(item); 29 | if (calcHist) { 30 | list.push(calcHist); 31 | } 32 | } 33 | } 34 | return list; 35 | } catch { 36 | return null; 37 | } 38 | } 39 | 40 | async update( 41 | id: string, 42 | newCalcHist: CalculationHistoryItem[], 43 | ): Promise { 44 | try { 45 | await this.calcHistDB.setItem( 46 | id, 47 | newCalcHist.map((item) => item), 48 | ); 49 | return true; 50 | } catch { 51 | return false; 52 | } 53 | } 54 | 55 | async delete(id: string): Promise { 56 | try { 57 | await this.calcHistDB.removeItem(id); 58 | return true; 59 | } catch { 60 | return false; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/guitos/infrastructure/localForageOptionsRepository.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage"; 2 | import { currenciesMap } from "../../lists/currenciesMap"; 3 | import { UserOptions } from "../domain/userOptions"; 4 | import type { UserOptionsRepository } from "../domain/userOptionsRepository"; 5 | 6 | export class localForageOptionsRepository implements UserOptionsRepository { 7 | private readonly optionsDB; 8 | 9 | constructor() { 10 | this.optionsDB = localforage.createInstance({ 11 | name: "guitos", 12 | storeName: "options", 13 | }); 14 | } 15 | 16 | async getCurrencyCode(): Promise { 17 | try { 18 | const code = await this.optionsDB.getItem( 19 | UserOptions.CURRENCY_CODE, 20 | ); 21 | if (!code) { 22 | return this.getDefaultCurrencyCode(); 23 | } 24 | return code; 25 | } catch (e) { 26 | throw new Error((e as Error).message); 27 | } 28 | } 29 | 30 | async saveCurrencyCode(options: UserOptions): Promise { 31 | try { 32 | await this.optionsDB.setItem( 33 | UserOptions.CURRENCY_CODE, 34 | options.currencyCode, 35 | ); 36 | return true; 37 | } catch { 38 | return false; 39 | } 40 | } 41 | 42 | async getLocale(): Promise { 43 | try { 44 | const locale = await this.optionsDB.getItem(UserOptions.LOCALE); 45 | if (!locale) throw new Error(); 46 | return locale; 47 | } catch (e) { 48 | throw new Error((e as Error).message); 49 | } 50 | } 51 | 52 | async saveLocale(options: UserOptions): Promise { 53 | try { 54 | await this.optionsDB.setItem(UserOptions.LOCALE, options.locale); 55 | return true; 56 | } catch { 57 | return false; 58 | } 59 | } 60 | 61 | getUserLang(): string { 62 | return navigator.language; 63 | } 64 | 65 | getCountryCode(locale: string): string { 66 | return locale.split("-").length >= 2 67 | ? locale.split("-")[1].toUpperCase() 68 | : locale.toUpperCase(); 69 | } 70 | 71 | getDefaultCurrencyCode(): string { 72 | const country = this.getCountryCode(this.getUserLang()); 73 | return this.getCurrencyCodeFromCountry(country); 74 | } 75 | 76 | getCurrencyCodeFromCountry(country: string): string { 77 | const countryIsInMap = 78 | currenciesMap[country as keyof typeof currenciesMap] !== undefined; 79 | 80 | if (countryIsInMap) { 81 | return currenciesMap[ 82 | country as keyof typeof currenciesMap 83 | ] as unknown as string; 84 | } 85 | return "USD"; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/guitos/sections/Budget/BudgetPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { act } from "react"; 4 | import { BrowserRouter } from "react-router"; 5 | import { describe, expect, it, vi } from "vitest"; 6 | import { 7 | budgetContextSpy, 8 | redoMock, 9 | setBudgetMock, 10 | testBudgetContext, 11 | undoMock, 12 | } from "../../../setupTests"; 13 | import { BudgetMother } from "../../domain/budget.mother"; 14 | import { localForageBudgetRepository } from "../../infrastructure/localForageBudgetRepository"; 15 | import { BudgetPage } from "./BudgetPage"; 16 | 17 | const budgetRepository = new localForageBudgetRepository(); 18 | 19 | describe("BudgetPage", () => { 20 | const setNotificationsMock = vi.fn(); 21 | const comp = ( 22 | 23 | 24 | 25 | ); 26 | 27 | it("matches snapshot", () => { 28 | render(comp); 29 | expect(comp).toMatchSnapshot(); 30 | }); 31 | 32 | it("renders initial state", async () => { 33 | render(comp); 34 | const newButton = screen.getAllByRole("button", { name: "new budget" }); 35 | await act(async () => { 36 | await userEvent.click(newButton[0]); 37 | }); 38 | expect(screen.getByLabelText("delete budget")).toBeVisible(); 39 | }); 40 | 41 | it("responds to new budget keyboard shortcut", async () => { 42 | render(comp); 43 | await userEvent.type(await screen.findByTestId("header"), "a"); 44 | expect(setBudgetMock).toHaveBeenCalled(); 45 | }); 46 | 47 | it("removes budget when clicking on delete budget button", async () => { 48 | render(comp); 49 | const deleteButton = await screen.findAllByRole("button", { 50 | name: "delete budget", 51 | }); 52 | await userEvent.click(deleteButton[0]); 53 | await userEvent.click( 54 | await screen.findByRole("button", { name: "confirm budget deletion" }), 55 | ); 56 | await expect( 57 | budgetRepository.get(BudgetMother.testBudget().id), 58 | ).rejects.toThrow(); 59 | }); 60 | 61 | it.skip("clones budget when clicking on clone budget button", async () => { 62 | render(comp); 63 | const newButton = await screen.findAllByRole("button", { 64 | name: "new budget", 65 | }); 66 | await userEvent.click(newButton[0]); 67 | 68 | const cloneButton = screen.getAllByRole("button", { 69 | name: "clone budget", 70 | }); 71 | await userEvent.click(cloneButton[0]); 72 | expect(setBudgetMock).toHaveBeenCalledWith( 73 | BudgetMother.testBudgetClone(), 74 | true, 75 | ); 76 | }); 77 | 78 | it.skip("responds to clone budget keyboard shortcut", async () => { 79 | render(comp); 80 | const newButton = await screen.findAllByRole("button", { 81 | name: "new budget", 82 | }); 83 | await userEvent.click(newButton[0]); 84 | 85 | await userEvent.type(await screen.findByTestId("header"), "c"); 86 | expect(setBudgetMock).toHaveBeenCalledWith( 87 | BudgetMother.testBudgetClone(), 88 | true, 89 | ); 90 | }); 91 | 92 | it("responds to undo change keyboard shortcut", async () => { 93 | cleanup(); 94 | budgetContextSpy.mockReturnValue({ ...testBudgetContext, canUndo: true }); 95 | render(comp); 96 | await userEvent.type(await screen.findByTestId("header"), "u"); 97 | expect(undoMock).toHaveBeenCalled(); 98 | }); 99 | 100 | it("responds to redo change keyboard shortcut", async () => { 101 | cleanup(); 102 | budgetContextSpy.mockReturnValue({ ...testBudgetContext, canRedo: true }); 103 | render(comp); 104 | await userEvent.type(await screen.findByTestId("header"), "r"); 105 | expect(redoMock).toHaveBeenCalled(); 106 | }); 107 | 108 | it.skip("responds to clear notifications keyboard shortcut", async () => { 109 | render(comp); 110 | setNotificationsMock.mockClear(); 111 | await userEvent.type(await screen.findByTestId("header"), "{Escape}"); 112 | expect(setNotificationsMock).toHaveBeenCalledWith([]); 113 | }); 114 | 115 | it("responds to show graphs keyboard shortcut", async () => { 116 | render(comp); 117 | await userEvent.type(await screen.findByTestId("header"), "i"); 118 | for (const element of screen.getAllByRole("status")) { 119 | expect(element).toBeVisible(); 120 | } 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/guitos/sections/Budget/__snapshots__/BudgetPage.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`BudgetPage > matches snapshot 1`] = ` 4 | 5 | 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/guitos/sections/CalculateButton/CalculateButton.css: -------------------------------------------------------------------------------- 1 | .dropdown, 2 | .dropdown-item, 3 | .dropdown-item:focus, 4 | .dropdown-item.disabled, 5 | .dropdown-item:disabled, 6 | .dropdown-item:hover { 7 | background: var(--lightbgcolor); 8 | color: var(--textcolor); 9 | } 10 | 11 | .dropdown-item:focus, 12 | .dropdown-item:focus-visible, 13 | .dropdown-item:hover { 14 | background: var(--lightbgcolor); 15 | border: 1px solid var(--pink); 16 | box-shadow: 0 0 0; 17 | color: var(--textcolor); 18 | } 19 | 20 | .dropdown-menu { 21 | min-width: inherit; 22 | overflow-x: hidden !important; 23 | } 24 | 25 | .dropdown-menu, 26 | .dropdown-menu.show { 27 | background: var(--lightbgcolor); 28 | border: 0 solid var(--comment); 29 | border-radius: 0.375rem; 30 | color: var(--textcolor); 31 | } 32 | -------------------------------------------------------------------------------- /src/guitos/sections/CalculateButton/CalculateButton.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { BrowserRouter } from "react-router"; 4 | import { vi } from "vitest"; 5 | import { describe, expect, it } from "vitest"; 6 | import { BudgetItemsMother } from "../../domain/budgetItem.mother"; 7 | import { CalculateButton } from "./CalculateButton"; 8 | 9 | describe("CalculateButton", () => { 10 | const onCalculate = vi.fn(); 11 | const comp = ( 12 | 13 | 18 | 19 | ); 20 | 21 | it("matches snapshot", () => { 22 | render(comp); 23 | expect(comp).toMatchSnapshot(); 24 | }); 25 | 26 | it("renders initial state", async () => { 27 | render(comp); 28 | expect( 29 | await screen.findByLabelText("select operation type to item value"), 30 | ).toBeInTheDocument(); 31 | }); 32 | 33 | it("opens popover when clicking the button", async () => { 34 | render(comp); 35 | const button = screen.getByRole("button", { 36 | name: "select operation type to item value", 37 | }); 38 | await userEvent.click(button); 39 | 40 | expect( 41 | screen.getByRole("button", { 42 | name: "select type of operation on item value", 43 | }), 44 | ).toBeInTheDocument(); 45 | 46 | expect(screen.getByLabelText("addition")).toBeInTheDocument(); 47 | 48 | expect( 49 | screen.getByRole("button", { 50 | name: "apply change to item value", 51 | }), 52 | ).toBeInTheDocument(); 53 | }); 54 | 55 | it.skip("closes when clicking the button", async () => { 56 | render(comp); 57 | await waitFor(async () => { 58 | const button = screen.getByRole("button", { 59 | name: "select operation type to item value", 60 | }); 61 | await userEvent.click(button); 62 | 63 | await userEvent.click(button); 64 | 65 | const button2 = await screen.findByRole("button", { 66 | name: "select type of operation on item value", 67 | }); 68 | 69 | expect(button2).not.toBeInTheDocument(); 70 | }); 71 | }); 72 | 73 | it.skip("closes when pressing Escape key", async () => { 74 | render(comp); 75 | await waitFor(async () => { 76 | const button = screen.getByRole("button", { 77 | name: "select operation type to item value", 78 | }); 79 | await userEvent.click(button); 80 | 81 | await userEvent.type(screen.getByLabelText("add"), "{Escape}"); 82 | const button2 = await screen.findByRole("button", { 83 | name: "select type of operation on item value", 84 | }); 85 | 86 | expect(button2).not.toBeInTheDocument(); 87 | }); 88 | }); 89 | 90 | it("calls onCalculate when accepting change > 0", async () => { 91 | render(comp); 92 | const button = screen.getByRole("button", { 93 | name: "select operation type to item value", 94 | }); 95 | await userEvent.click(button); 96 | const acceptButton = screen.getByRole("button", { 97 | name: "apply change to item value", 98 | }); 99 | 100 | await userEvent.type(screen.getByLabelText("add"), "123"); 101 | await userEvent.click(acceptButton); 102 | 103 | expect(onCalculate).toHaveBeenCalledWith(123, "add"); 104 | }); 105 | 106 | it("calls onCalculate when change > 0 and enter is pressed", async () => { 107 | render(comp); 108 | const button = screen.getByRole("button", { 109 | name: "select operation type to item value", 110 | }); 111 | await userEvent.click(button); 112 | await userEvent.type(screen.getByLabelText("add"), "123"); 113 | await userEvent.type(screen.getByLabelText("add"), "{enter}"); 114 | 115 | expect(onCalculate).toHaveBeenCalledWith(123, "add"); 116 | }); 117 | 118 | it("calls onCalculate with sub", async () => { 119 | render(comp); 120 | const button = screen.getByRole("button", { 121 | name: "select operation type to item value", 122 | }); 123 | await userEvent.click(button); 124 | const acceptButton = screen.getByRole("button", { 125 | name: "apply change to item value", 126 | }); 127 | 128 | await userEvent.type(screen.getByLabelText("add"), "123"); 129 | 130 | await userEvent.click(screen.getByLabelText("subtraction")); 131 | await userEvent.click(acceptButton); 132 | 133 | expect(onCalculate).toHaveBeenCalledWith(123, "subtract"); 134 | }); 135 | 136 | it("calls onCalculate with multiply", async () => { 137 | render(comp); 138 | const button = screen.getByRole("button", { 139 | name: "select operation type to item value", 140 | }); 141 | await userEvent.click(button); 142 | const acceptButton = screen.getByRole("button", { 143 | name: "apply change to item value", 144 | }); 145 | 146 | await userEvent.type(screen.getByLabelText("add"), "123"); 147 | 148 | await userEvent.click(screen.getByLabelText("multiplication")); 149 | await userEvent.click(acceptButton); 150 | 151 | expect(onCalculate).toHaveBeenCalledWith(123, "multiply"); 152 | }); 153 | 154 | it("calls onCalculate with div", async () => { 155 | render(comp); 156 | const button = screen.getByRole("button", { 157 | name: "select operation type to item value", 158 | }); 159 | await userEvent.click(button); 160 | const acceptButton = screen.getByRole("button", { 161 | name: "apply change to item value", 162 | }); 163 | 164 | await userEvent.type(screen.getByLabelText("add"), "123"); 165 | 166 | await userEvent.click(screen.getByLabelText("division")); 167 | await userEvent.click(acceptButton); 168 | 169 | expect(onCalculate).toHaveBeenCalledWith(123, "divide"); 170 | }); 171 | 172 | it.skip("shows history when clicking button", async () => { 173 | render(comp); 174 | await waitFor(async () => { 175 | const button = screen.getByRole("button", { 176 | name: "select operation type to item value", 177 | }); 178 | await userEvent.click(button); 179 | const historyButton = screen.getByRole("button", { 180 | name: "open operation history", 181 | }); 182 | await userEvent.click(historyButton); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /src/guitos/sections/CalculateButton/__snapshots__/CalculateButton.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`CalculateButton > matches snapshot 1`] = ` 4 | 5 | 16 | 17 | `; 18 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/Chart.css: -------------------------------------------------------------------------------- 1 | .recharts-default-tooltip, 2 | .recharts-tooltip-wrapper { 3 | background-color: var(--lightbgcolor); 4 | border: 1px solid var(--lightbgcolor); 5 | } 6 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/Chart.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { vi } from "vitest"; 3 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; 4 | import { BudgetMother } from "../../domain/budget.mother"; 5 | import { Chart } from "./Chart"; 6 | 7 | describe("Chart", () => { 8 | const comp = ( 9 | { 17 | return b.incomes.total; 18 | })} 19 | /> 20 | ); 21 | 22 | beforeEach(() => { 23 | //@ts-ignore 24 | window.ResizeObserver = undefined; 25 | window.ResizeObserver = vi.fn().mockImplementation(() => ({ 26 | observe: vi.fn(), 27 | unobserve: vi.fn(), 28 | disconnect: vi.fn(), 29 | })); 30 | }); 31 | 32 | afterEach(() => { 33 | window.ResizeObserver = ResizeObserver; 34 | vi.restoreAllMocks(); 35 | }); 36 | 37 | it("matches snapshot", () => { 38 | render(comp); 39 | expect(comp).toMatchSnapshot(); 40 | }); 41 | 42 | it("renders initial state", () => { 43 | render(comp); 44 | expect(screen.getByText("chart header")).toBeInTheDocument(); 45 | expect(screen.getByText("median revenue")).toBeInTheDocument(); 46 | expect(screen.getByDisplayValue("$200")).toBeInTheDocument(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Col, Form, InputGroup, Row } from "react-bootstrap"; 2 | import CurrencyInput from "react-currency-input-field"; 3 | import { BsPercent } from "react-icons/bs"; 4 | import { 5 | Area, 6 | AreaChart, 7 | ResponsiveContainer, 8 | Tooltip, 9 | XAxis, 10 | YAxis, 11 | } from "recharts"; 12 | import { intlFormat, median } from "../../../utils"; 13 | import { useBudget } from "../../context/BudgetContext"; 14 | import { useConfig } from "../../context/ConfigContext"; 15 | import type { FilteredItem } from "../ChartsPage/ChartsPage"; 16 | import "./Chart.css"; 17 | import { useState } from "react"; 18 | import { ChartTooltip } from "./ChartTooltip"; 19 | 20 | interface ChartProps { 21 | header: string; 22 | tooltipKey1?: string; 23 | tooltipKey2?: string; 24 | areaDataKey1: string; 25 | areaDataKey2?: string; 26 | areaStroke1: string; 27 | areaFill1: string; 28 | areaStroke2?: string; 29 | areaFill2?: string; 30 | legend1: string; 31 | legendValues1: number[]; 32 | legend2?: string; 33 | legendValues2?: number[]; 34 | unit?: string; 35 | filteredData?: FilteredItem[]; 36 | } 37 | 38 | const horizontalRatio = 3.4; 39 | const verticalRatio = 1.6; 40 | 41 | export function Chart({ 42 | header, 43 | tooltipKey1, 44 | tooltipKey2, 45 | areaDataKey1, 46 | areaDataKey2, 47 | areaStroke1, 48 | areaStroke2, 49 | areaFill1, 50 | areaFill2, 51 | legend1, 52 | legend2, 53 | legendValues1, 54 | legendValues2, 55 | unit, 56 | filteredData, 57 | }: ChartProps) { 58 | const { budgetList } = useBudget(); 59 | const { userOptions, intlConfig } = useConfig(); 60 | 61 | const showSecondArea = areaDataKey2 && areaStroke2 && areaFill2; 62 | const isGoalChart = tooltipKey1 === "goal"; 63 | const isVerticalScreen = window.innerWidth < window.innerHeight; 64 | const chartData = 65 | filteredData ?? budgetList?.sort((a, b) => a.name.localeCompare(b.name)); 66 | 67 | const [longestTick, setLongestTick] = useState(""); 68 | 69 | const getYAxisTickWidth = (): number => { 70 | const charWidth = 8; 71 | return longestTick.length * charWidth + 30; 72 | }; 73 | 74 | function medianLabelGroup(legend: string, values: number[]) { 75 | return ( 76 | 77 | {legend} 78 | 85 | 86 | ); 87 | } 88 | 89 | function tickFormatter(value: number) { 90 | const formattedTick = intlFormat(value, userOptions) ?? ""; 91 | if (longestTick.length < formattedTick.length) { 92 | setLongestTick(formattedTick); 93 | } 94 | return formattedTick; 95 | } 96 | 97 | return ( 98 | 99 | {header} 100 | 101 | 105 | 113 | 119 | {unit ? ( 120 | 126 | ) : ( 127 | 133 | )} 134 | } 138 | contentStyle={{ backgroundColor: "var(--bgcolor)" }} 139 | itemStyle={{ color: "var(--textcolor)" }} 140 | /> 141 | 148 | {showSecondArea && ( 149 | 156 | )} 157 | 158 | 159 | 160 | 161 | 162 | {isGoalChart && ( 163 | 164 | {legend1} 165 | 172 | 173 | 174 | 175 | 176 | )} 177 | {!isGoalChart && 178 | !legendValues2 && 179 | medianLabelGroup(legend1, legendValues1)} 180 | {!isGoalChart && legendValues2 && ( 181 | {medianLabelGroup(legend1, legendValues1)} 182 | )} 183 | {legend2 && legendValues2 && ( 184 | {medianLabelGroup(legend2, legendValues2)} 185 | )} 186 | 187 | 188 | 189 | ); 190 | } 191 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/ChartTooltip.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import { ChartTooltip } from "./ChartTooltip"; 4 | 5 | describe("ChartTooltip", () => { 6 | const comp = ( 7 | 12 | ); 13 | 14 | it("matches snapshot", () => { 15 | render(comp); 16 | expect(comp).toMatchSnapshot(); 17 | }); 18 | 19 | it("renders initial state", () => { 20 | render(comp); 21 | expect(screen.getByText("label")).toBeInTheDocument(); 22 | expect(screen.getByText("$123.00")).toBeInTheDocument(); 23 | }); 24 | 25 | it("renders goal with %", () => { 26 | render(comp); 27 | render( 28 | , 34 | ); 35 | expect(screen.getByText("123%")).toBeInTheDocument(); 36 | }); 37 | 38 | it("renders 2 legends", () => { 39 | render(comp); 40 | render( 41 | , 50 | ); 51 | expect(screen.getByText("$456.00")).toBeInTheDocument(); 52 | expect(screen.getByText("$789.00")).toBeInTheDocument(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/ChartTooltip.tsx: -------------------------------------------------------------------------------- 1 | import Big from "big.js"; 2 | import { Col, Container, Row } from "react-bootstrap"; 3 | import { intlFormat, roundBig } from "../../../utils"; 4 | import { useConfig } from "../../context/ConfigContext"; 5 | 6 | interface ChartTooltipProps { 7 | active?: boolean; 8 | payload?: { 9 | name: string; 10 | payload?: { 11 | id: string; 12 | item: string; 13 | name: string; 14 | type: string; 15 | value: number; 16 | }; 17 | value: number; 18 | unit: string; 19 | }[]; 20 | label?: string; 21 | key1?: string | undefined; 22 | key2?: string | undefined; 23 | } 24 | 25 | export function ChartTooltip({ 26 | active, 27 | payload, 28 | label, 29 | key1, 30 | key2, 31 | }: ChartTooltipProps) { 32 | const { userOptions } = useConfig(); 33 | const showTooltip = active && payload?.length; 34 | const filteredItemName = payload?.length && payload[0].payload?.item; 35 | const showMultipleLegends = showTooltip && payload.length > 1; 36 | 37 | const item1Value = 38 | showTooltip && intlFormat(roundBig(Big(payload[0].value), 2), userOptions); 39 | const item2Value = 40 | showMultipleLegends && 41 | intlFormat(roundBig(Big(payload[1].value), 2), userOptions); 42 | 43 | return showTooltip ? ( 44 | 45 | {label} 46 | {showMultipleLegends ? ( 47 | <> 48 | 49 | {`${key1 ?? ""}:`} 50 | {item1Value} 51 | 52 | 53 | {`${key2 ?? ""}:`} 54 | 55 | {item2Value} 56 | 57 | 58 | 59 | ) : ( 60 | 61 | {`${key1 ?? filteredItemName}:`} 62 | {key1 === "goal" ? ( 63 | {`${payload[0].value}%`} 64 | ) : ( 65 | {item1Value} 66 | )} 67 | 68 | )} 69 | 70 | ) : null; 71 | } 72 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/__snapshots__/Chart.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Chart > matches snapshot 1`] = ` 4 | 19 | `; 20 | -------------------------------------------------------------------------------- /src/guitos/sections/Chart/__snapshots__/ChartTooltip.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ChartTooltip > matches snapshot 1`] = ` 4 | 17 | `; 18 | -------------------------------------------------------------------------------- /src/guitos/sections/ChartsPage/ChartsPage.css: -------------------------------------------------------------------------------- 1 | .filter-search { 2 | border-radius: 0.375rem; 3 | margin-right: 2px; 4 | } 5 | -------------------------------------------------------------------------------- /src/guitos/sections/ChartsPage/ChartsPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { BrowserRouter } from "react-router"; 4 | import { vi } from "vitest"; 5 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; 6 | import ChartsPage from "./ChartsPage"; 7 | 8 | describe("ChartsPage", () => { 9 | const onShowGraphs = vi.fn(); 10 | const comp = ( 11 | 12 | 13 | 14 | ); 15 | 16 | beforeEach(() => { 17 | //@ts-ignore 18 | window.ResizeObserver = undefined; 19 | window.ResizeObserver = vi.fn().mockImplementation(() => ({ 20 | observe: vi.fn(), 21 | unobserve: vi.fn(), 22 | disconnect: vi.fn(), 23 | })); 24 | }); 25 | 26 | afterEach(() => { 27 | window.ResizeObserver = ResizeObserver; 28 | vi.restoreAllMocks(); 29 | }); 30 | 31 | it("matches snapshot", () => { 32 | render(comp); 33 | expect(comp).toMatchSnapshot(); 34 | }); 35 | 36 | it("renders initial state", () => { 37 | render(comp); 38 | expect(screen.getByLabelText("go back to budgets")).toBeInTheDocument(); 39 | expect(screen.getByText("Revenue vs expenses")).toBeInTheDocument(); 40 | expect(screen.getByText("Savings")).toBeInTheDocument(); 41 | expect(screen.getByText("Reserves")).toBeInTheDocument(); 42 | expect(screen.getByText("Available vs with goal")).toBeInTheDocument(); 43 | expect(screen.getByText("Savings goal")).toBeInTheDocument(); 44 | }); 45 | 46 | it("triggers onShowGraphs when back button is pressed", async () => { 47 | render(comp); 48 | await userEvent.click(screen.getByLabelText("go back to budgets")); 49 | expect(onShowGraphs).toHaveBeenCalledTimes(1); 50 | onShowGraphs.mockClear(); 51 | }); 52 | 53 | it("triggers onShowGraphs when i shortcut is pressed", async () => { 54 | render(comp); 55 | await userEvent.type(screen.getByText("Reserves"), "i"); 56 | expect(onShowGraphs).toHaveBeenCalledTimes(1); 57 | onShowGraphs.mockClear(); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/guitos/sections/ChartsPage/__snapshots__/ChartsPage.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ChartsPage > matches snapshot 1`] = ` 4 | 5 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /src/guitos/sections/ErrorModal/ErrorModal.css: -------------------------------------------------------------------------------- 1 | .accordion-button, 2 | .accordion-flush, 3 | .accordion-item, 4 | .modal-item { 5 | border-radius: 0.375rem; 6 | } 7 | 8 | .accordion, 9 | .accordion-button, 10 | .accordion-button:focus, 11 | .accordion-item, 12 | .accordion-item:focus { 13 | background: var(--lightbgcolor); 14 | border: 0 solid var(--comment); 15 | border-radius: 0.375rem; 16 | color: var(--textcolor); 17 | } 18 | 19 | .accordion-button::after, 20 | .accordion-button:not(.collapsed)::after { 21 | border-radius: 0.375rem; 22 | filter: invert(58%) sepia(93%) saturate(2284%) hue-rotate(322deg) 23 | brightness(95%) contrast(112%); 24 | } 25 | 26 | .accordion-button:focus, 27 | .accordion-item:focus { 28 | border-radius: 0.375rem; 29 | box-shadow: 0 0 0 0.2rem rgb(189 147 249 / 25%); 30 | } 31 | 32 | .btn-delete-modal { 33 | background-color: var(--lightbgcolor); 34 | border: 1px solid var(--lightbgcolor); 35 | border-radius: 0.375rem; 36 | color: var(--red); 37 | } 38 | 39 | .btn-delete-modal:focus-visible, 40 | .btn-delete-modal:hover { 41 | background-color: var(--red); 42 | border: 1px solid var(--red); 43 | border-radius: 0.375rem; 44 | color: var(--lightbgcolor); 45 | } 46 | 47 | .modal-90w { 48 | max-width: none !important; 49 | width: 90%; 50 | } 51 | 52 | .modal-body { 53 | max-height: 65vh; 54 | overflow: auto; 55 | } 56 | 57 | .modal-content, 58 | .modal-item { 59 | background: var(--lightbgcolor); 60 | border: 1px solid var(--lightbgcolor); 61 | color: var(--textcolor); 62 | } 63 | 64 | .modal-header { 65 | border-bottom: 0; 66 | } 67 | -------------------------------------------------------------------------------- /src/guitos/sections/ErrorModal/ErrorModal.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | import type { CsvError } from "../../domain/csvError"; 5 | import { CsvErrorMother } from "../../domain/csvError.mother"; 6 | import type { JsonError } from "../../domain/jsonError"; 7 | import { JsonErrorMother } from "../../domain/jsonError.mother"; 8 | import { ErrorModal } from "./ErrorModal"; 9 | 10 | const error = "Thrown error"; 11 | const jsonErrors: JsonError[] = [JsonErrorMother.error()]; 12 | 13 | const csvErrors: CsvError[] = [CsvErrorMother.error()]; 14 | 15 | const setShowError = vi.fn(); 16 | const setShowCsvErrors = vi.fn(); 17 | const setShowJsonErrors = vi.fn(); 18 | const handleDismiss = vi.fn(); 19 | 20 | describe("ErrorModal", () => { 21 | const comp = ( 22 | 32 | ); 33 | 34 | it("matches snapshot", () => { 35 | render(comp); 36 | expect(comp).toMatchSnapshot(); 37 | }); 38 | 39 | it("renders initial state", () => { 40 | render(comp); 41 | expect( 42 | screen.getAllByText("Errors found while importing:")[0], 43 | ).toBeInTheDocument(); 44 | expect( 45 | screen.getByText( 46 | "SyntaxError: Expected ',' or '}' after property value in JSON at position 209", 47 | ), 48 | ).toBeInTheDocument(); 49 | }); 50 | 51 | it("closes error when clicking the button", async () => { 52 | render(comp); 53 | expect(screen.getByTestId("error-modal")).toBeInTheDocument(); 54 | await userEvent.click(screen.getByTestId("error-modal-dismiss")); 55 | }); 56 | 57 | it("closes json error when clicking the button", async () => { 58 | render(comp); 59 | expect(screen.getByTestId("json-error-close")).toBeInTheDocument(); 60 | await userEvent.click(screen.getByTestId("json-error-close")); 61 | }); 62 | 63 | it("closes json error modal when clicking the button", async () => { 64 | render(comp); 65 | expect(screen.getByTestId("json-error-modal")).toBeInTheDocument(); 66 | await userEvent.click(screen.getByTestId("json-error-modal")); 67 | }); 68 | 69 | it("closes csv error when clicking the button", async () => { 70 | render(comp); 71 | 72 | expect(screen.getByTestId("csv-error-close")).toBeInTheDocument(); 73 | await userEvent.click(screen.getByTestId("csv-error-close")); 74 | }); 75 | 76 | it("closes csv error modal when clicking the button", async () => { 77 | render(comp); 78 | 79 | expect(screen.getByTestId("csv-error-modal")).toBeInTheDocument(); 80 | await userEvent.click(screen.getByTestId("csv-error-modal")); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/guitos/sections/ErrorModal/ErrorModal.tsx: -------------------------------------------------------------------------------- 1 | import { Accordion, Button, Modal } from "react-bootstrap"; 2 | import { BsXLg } from "react-icons/bs"; 3 | import "./ErrorModal.css"; 4 | import type { CsvError } from "../../domain/csvError"; 5 | import type { JsonError } from "../../domain/jsonError"; 6 | 7 | interface ErrorModalProps { 8 | error: string | null; 9 | setShowError: (value: boolean) => void; 10 | showError: boolean; 11 | jsonErrors: JsonError[]; 12 | setJsonErrors: (value: JsonError[]) => void; 13 | csvErrors: CsvError[]; 14 | setCsvErrors: (value: CsvError[]) => void; 15 | handleDismiss: () => void; 16 | } 17 | export function ErrorModal({ 18 | error, 19 | setShowError, 20 | showError, 21 | jsonErrors, 22 | csvErrors, 23 | handleDismiss, 24 | }: ErrorModalProps) { 25 | const showModal = error && showError; 26 | const showJsonError = jsonErrors && jsonErrors.length > 0 && showError; 27 | const showCsvError = csvErrors && csvErrors.length > 0 && showError; 28 | 29 | return ( 30 | <> 31 | {showModal && ( 32 | setShowError(false)} 37 | centered={true} 38 | > 39 | 40 | Error: 41 | 51 | 52 | 53 |

{error}

54 |
55 |
56 | )} 57 | 58 | {showJsonError && ( 59 | setShowError(false)} 65 | centered={true} 66 | > 67 | 68 | Errors found while importing: 69 | 80 | 81 | 82 | 87 | {jsonErrors.map((jsonError: JsonError, i: number) => ( 88 | 92 | 96 | {jsonError.file} 97 | 98 | 102 |

103 | <> 104 | {jsonError.errors} 105 |
106 | 107 |

108 |
109 |
110 | ))} 111 |
112 |
113 |
114 | )} 115 | 116 | {showCsvError && ( 117 | setShowError(false)} 123 | centered={true} 124 | > 125 | 126 | Errors found while importing: 127 | 138 | 139 | 140 | 145 | {csvErrors.map((csvError: CsvError, i: number) => ( 146 | 150 | 154 | {csvError.file} 155 | 156 | 160 |

161 | {csvError.errors.map((error) => ( 162 | 163 | Line {error.row}: {error.message} 164 |
165 |
166 | ))} 167 |

168 |
169 |
170 | ))} 171 |
172 |
173 |
174 | )} 175 | 176 | ); 177 | } 178 | -------------------------------------------------------------------------------- /src/guitos/sections/ErrorModal/__snapshots__/ErrorModal.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ErrorModal > matches snapshot 1`] = ` 4 | 35 | `; 36 | -------------------------------------------------------------------------------- /src/guitos/sections/ItemForm/ItemFormGroup.css: -------------------------------------------------------------------------------- 1 | .btn-delete { 2 | background-color: var(--bgcolor); 3 | border: 1px solid var(--lightbgcolor); 4 | border-radius: 0.375rem; 5 | color: var(--red); 6 | } 7 | 8 | .btn-delete:focus-visible, 9 | .btn-delete:hover { 10 | background-color: var(--red); 11 | border: 1px solid var(--red); 12 | border-radius: 0.375rem; 13 | color: var(--lightbgcolor); 14 | } 15 | -------------------------------------------------------------------------------- /src/guitos/sections/ItemForm/ItemFormGroup.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { createRef } from "react"; 4 | import { BrowserRouter } from "react-router"; 5 | import { describe, expect, it } from "vitest"; 6 | import { setBudgetMock } from "../../../setupTests"; 7 | import { BudgetMother } from "../../domain/budget.mother"; 8 | import { BudgetItemsMother } from "../../domain/budgetItem.mother"; 9 | import { UserOptionsMother } from "../../domain/userOptions.mother"; 10 | import { ItemFormGroup } from "./ItemFormGroup"; 11 | 12 | describe("ItemFormGroup", () => { 13 | const ref = createRef(); 14 | const comp = ( 15 | 16 | 23 | 24 | ); 25 | 26 | it("matches snapshot", () => { 27 | render(comp); 28 | expect(comp).toMatchSnapshot(); 29 | }); 30 | 31 | it("renders initial state", async () => { 32 | render(comp); 33 | expect(await screen.findByDisplayValue("name1")).toBeInTheDocument(); 34 | expect(await screen.findByDisplayValue("$10")).toBeInTheDocument(); 35 | }); 36 | 37 | it("reacts to user changing input", async () => { 38 | render(comp); 39 | setBudgetMock.mockClear(); 40 | await userEvent.type(screen.getByDisplayValue("name1"), "change name"); 41 | 42 | expect(screen.getByDisplayValue("name1change name")).toBeInTheDocument(); 43 | expect(setBudgetMock).toHaveBeenCalledWith( 44 | { 45 | ...BudgetMother.testBudget(), 46 | expenses: { 47 | items: [{ id: 1, name: "name1change name", value: 10 }], 48 | total: 10, 49 | }, 50 | }, 51 | false, 52 | ); 53 | 54 | setBudgetMock.mockClear(); 55 | 56 | await userEvent.type(screen.getByDisplayValue("$10"), "123"); 57 | 58 | expect(screen.getByDisplayValue("$123")).toBeInTheDocument(); 59 | expect(setBudgetMock).toHaveBeenCalledWith( 60 | { 61 | ...BudgetMother.testBudget(), 62 | expenses: { 63 | items: [{ id: 1, name: "expense1", value: 123 }], 64 | total: 123, 65 | }, 66 | stats: { 67 | ...BudgetMother.testBudget().stats, 68 | available: -23, 69 | withGoal: -33, 70 | }, 71 | }, 72 | false, 73 | ); 74 | }); 75 | 76 | it("removes item when user clicks delete confirmation button", async () => { 77 | render(comp); 78 | setBudgetMock.mockClear(); 79 | await userEvent.click( 80 | screen.getByRole("button", { name: "delete item 1" }), 81 | ); 82 | await userEvent.click( 83 | screen.getByRole("button", { name: "confirm item 1 deletion" }), 84 | ); 85 | 86 | expect(setBudgetMock).toHaveBeenCalledWith( 87 | { 88 | ...BudgetMother.testBudget(), 89 | expenses: { items: [], total: 0 }, 90 | stats: { 91 | ...BudgetMother.testBudget().stats, 92 | available: 100, 93 | withGoal: 90, 94 | }, 95 | }, 96 | true, 97 | ); 98 | }); 99 | 100 | it("shows tooltip when user hovers over", async () => { 101 | render(comp); 102 | await userEvent.hover(screen.getByDisplayValue("$10")); 103 | 104 | expect(await screen.findByText("1% of revenue")).toBeInTheDocument(); 105 | }); 106 | 107 | it("opens popover when clicking the button", async () => { 108 | render(comp); 109 | await userEvent.click( 110 | screen.getByRole("button", { 111 | name: "select operation type to item value", 112 | }), 113 | ); 114 | expect( 115 | screen.getByLabelText("select type of operation on item value"), 116 | ).toBeInTheDocument(); 117 | }); 118 | 119 | it("transforms decimal separator based on locale", async () => { 120 | render( 121 | 122 | 129 | , 130 | , 131 | ); 132 | 133 | await userEvent.clear(screen.getByDisplayValue("10 €")); 134 | await userEvent.type(screen.getByLabelText("item 1 value"), ",12"); 135 | 136 | expect(screen.getByDisplayValue("0,12 €")).toBeInTheDocument(); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/guitos/sections/ItemForm/__snapshots__/ItemFormGroup.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`ItemFormGroup > matches snapshot 1`] = ` 4 | 5 | , 17 | } 18 | } 19 | itemForm={ 20 | BudgetItem { 21 | "id": 1, 22 | "name": "name1", 23 | "value": 10, 24 | } 25 | } 26 | label="Expenses" 27 | userOptions={ 28 | UserOptions { 29 | "currencyCode": "USD", 30 | "locale": "en-US", 31 | } 32 | } 33 | /> 34 | 35 | `; 36 | -------------------------------------------------------------------------------- /src/guitos/sections/LandingPage/LandingPage.css: -------------------------------------------------------------------------------- 1 | .version { 2 | color: var(--comment); 3 | } 4 | 5 | .balanced { 6 | text-wrap: pretty; 7 | } 8 | -------------------------------------------------------------------------------- /src/guitos/sections/LandingPage/LandingPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { BrowserRouter } from "react-router"; 4 | import { describe, expect, it } from "vitest"; 5 | import { setBudgetMock } from "../../../setupTests"; 6 | import { BudgetMother } from "../../domain/budget.mother"; 7 | import { LandingPage } from "./LandingPage"; 8 | 9 | describe("LandingPage", () => { 10 | const comp = ( 11 | 12 | 13 | 14 | ); 15 | 16 | it("matches snapshot", () => { 17 | render(comp); 18 | expect(comp).toMatchSnapshot(); 19 | }); 20 | 21 | it("renders initial state", () => { 22 | render(comp); 23 | expect(screen.getByLabelText("new budget")).toBeInTheDocument(); 24 | expect(screen.getByLabelText("import budget")).toBeInTheDocument(); 25 | expect( 26 | screen.getByLabelText("open instructions in new tab"), 27 | ).toBeInTheDocument(); 28 | }); 29 | 30 | it("triggers new budget", async () => { 31 | render(comp); 32 | setBudgetMock.mockClear(); 33 | const newButton = screen.getAllByRole("button", { name: "new budget" })[0]; 34 | await userEvent.click(newButton); 35 | }); 36 | 37 | it("triggers upload", async () => { 38 | render(comp); 39 | const uploadEl = screen.getByTestId("import-form-control-landing-page"); 40 | await userEvent.upload( 41 | uploadEl, 42 | new File([JSON.stringify(BudgetMother.testBudget())], "test"), 43 | ); 44 | expect((uploadEl as HTMLInputElement).files).toHaveLength(1); 45 | }); 46 | 47 | it("opens instructions in new tab", async () => { 48 | render(comp); 49 | const instructionsButton = screen.getByLabelText( 50 | "open instructions in new tab", 51 | ); 52 | await userEvent.click(instructionsButton); 53 | expect(instructionsButton).toHaveAttribute( 54 | "href", 55 | "https://github.com/rare-magma/guitos#getting-started", 56 | ); 57 | }); 58 | 59 | it("renders loading spinner", () => { 60 | render( 61 | 62 | 63 | , 64 | ); 65 | expect(screen.getByRole("status")).toBeInTheDocument(); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/guitos/sections/LandingPage/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useRef } from "react"; 3 | import { Button, Container, Form, Row, Stack } from "react-bootstrap"; 4 | import { useDB } from "../../hooks/useDB"; 5 | import { Loading } from "../Loading/Loading"; 6 | import "./LandingPage.css"; 7 | import { useWindowSize } from "../../hooks/useWindowSize"; 8 | 9 | interface LandingPageProps { 10 | loadingFromDB: boolean; 11 | showLandingPage: boolean; 12 | } 13 | 14 | export function LandingPage({ 15 | loadingFromDB, 16 | showLandingPage, 17 | }: LandingPageProps) { 18 | const inputRef = useRef(null); 19 | const { createBudget, handleImport } = useDB(); 20 | const size = useWindowSize(); 21 | const verticalScreen = size.width < 1000; 22 | const buttonWidth = verticalScreen ? "w-50" : "w-25"; 23 | const titleWidth = verticalScreen ? "w-75" : "w-50"; 24 | 25 | return ( 26 | <> 27 | {loadingFromDB && } 28 | 29 | {showLandingPage && ( 30 | 31 | 32 |

35 |

36 | Figure out where your money went, plan ahead of time and analyze 37 | past expenditures. 38 |

39 |

40 |
41 | 42 | 43 | 51 | 55 | 63 | ) => 69 | handleImport(e) 70 | } 71 | style={{ display: "none" }} 72 | /> 73 | 74 | 83 | 84 | 85 |
86 | )} 87 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/guitos/sections/LandingPage/__snapshots__/LandingPage.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`LandingPage > matches snapshot 1`] = ` 4 | 5 | 9 | 10 | `; 11 | -------------------------------------------------------------------------------- /src/guitos/sections/Loading/Loading.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { describe, expect, it } from "vitest"; 3 | import { Loading } from "./Loading"; 4 | 5 | describe("Loading", () => { 6 | const comp = ; 7 | 8 | it("matches snapshot", () => { 9 | render(comp); 10 | expect(comp).toMatchSnapshot(); 11 | }); 12 | 13 | it("renders initial state", () => { 14 | render(comp); 15 | expect(screen.getByRole("status")).toBeInTheDocument(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/guitos/sections/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Row, Spinner } from "react-bootstrap"; 2 | 3 | export function Loading() { 4 | return ( 5 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/guitos/sections/Loading/__snapshots__/Loading.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Loading > matches snapshot 1`] = ``; 4 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/NavBar.css: -------------------------------------------------------------------------------- 1 | .brand { 2 | color: var(--textcolor); 3 | } 4 | 5 | .btn-go-button { 6 | background-color: var(--lightbgcolor); 7 | border: 1px solid var(--lightbgcolor); 8 | color: var(--purple); 9 | } 10 | 11 | .btn-go-button:focus-visible, 12 | .btn-go-button:hover { 13 | background-color: var(--purple); 14 | border: 1px solid var(--purple); 15 | color: var(--lightbgcolor); 16 | } 17 | 18 | .btn-go-button:disabled { 19 | background-color: var(--lightbgcolor); 20 | border: 1px solid var(--lightbgcolor); 21 | color: var(--comment); 22 | } 23 | 24 | .btn-outline-danger { 25 | background-color: var(--lightbgcolor); 26 | border: 1px solid var(--lightbgcolor); 27 | border-radius: 0.375rem; 28 | color: var(--red); 29 | } 30 | 31 | .btn-outline-danger:focus-visible, 32 | .btn-outline-danger:hover { 33 | background-color: var(--red); 34 | border: 1px solid var(--red); 35 | border-radius: 0.375rem; 36 | color: var(--lightbgcolor); 37 | } 38 | 39 | .btn-outline-info { 40 | background-color: var(--lightbgcolor); 41 | border: 1px solid var(--lightbgcolor); 42 | border-radius: 0.375rem; 43 | color: var(--cyan); 44 | } 45 | 46 | .btn-outline-info:focus-visible, 47 | .btn-outline-info:hover { 48 | background-color: var(--cyan); 49 | border: 1px solid var(--cyan); 50 | border-radius: 0.375rem; 51 | color: var(--lightbgcolor); 52 | } 53 | 54 | .btn-outline-info:disabled { 55 | background-color: var(--lightbgcolor); 56 | border: 1px solid var(--lightbgcolor); 57 | border-radius: 0.375rem; 58 | color: var(--comment); 59 | } 60 | 61 | /* Search input placeholder */ 62 | input[type="text"]::placeholder { 63 | color: var(--comment); 64 | opacity: 1; 65 | } 66 | 67 | .navbar, 68 | .offcanvas-body, 69 | .offcanvas-header { 70 | background: var(--bgcolor); 71 | color: var(--textcolor); 72 | } 73 | 74 | .navbar .navbar-toggler-icon { 75 | background-image: url("data:image/svg+xml;utf8,💸"); 76 | } 77 | 78 | .navbar-toggler { 79 | background-color: var(--lightbgcolor); 80 | border: 1px solid var(--lightbgcolor); 81 | border-radius: 0.375rem; 82 | } 83 | 84 | .version { 85 | color: var(--textcolor); 86 | font-family: ui-monospace, "SF Mono", Menlo, Monaco, "Andale Mono", monospace; 87 | font-size: 0.9em; 88 | } 89 | 90 | .rbt-menu > .dropdown-item { 91 | text-align: start; 92 | overflow: visible; 93 | } 94 | 95 | .dropdown-item.active, 96 | .dropdown-item:active { 97 | background: var(--lightbgcolor); 98 | border: 1px solid var(--pink); 99 | box-shadow: 0 0 0; 100 | color: var(--textcolor); 101 | } 102 | 103 | .form-control.rbt-input-main { 104 | background: var(--lightbgcolor); 105 | } 106 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/NavBar.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { BrowserRouter } from "react-router"; 4 | import { beforeEach, describe, expect, it, vi } from "vitest"; 5 | import { 6 | budgetContextSpy, 7 | setBudgetMock, 8 | testEmptyBudgetContext, 9 | } from "../../../setupTests"; 10 | import { BudgetMother } from "../../domain/budget.mother"; 11 | import { NavBar } from "./NavBar"; 12 | 13 | const changelogRegex = /open guitos changelog/i; 14 | const importRegex = /import budget/i; 15 | const exportCsvRegex = /export budget as csv/i; 16 | const exportJsonRegex = /export budget as json/i; 17 | 18 | describe("NavBar", () => { 19 | const windowSpy = vi.spyOn(window, "open").mockImplementation(() => null); 20 | 21 | beforeEach(() => { 22 | windowSpy.mockClear(); 23 | }); 24 | 25 | const comp = ( 26 | 27 | 28 | 29 | ); 30 | 31 | it("matches snapshot", () => { 32 | render(comp); 33 | expect(comp).toMatchSnapshot(); 34 | }); 35 | 36 | it("renders initial state", async () => { 37 | render(comp); 38 | expect(screen.getByText("2023-03")).toBeInTheDocument(); 39 | 40 | const newButton = screen.getAllByRole("button", { name: "new budget" }); 41 | await userEvent.click(newButton[0]); 42 | await userEvent.click(newButton[0]); 43 | await userEvent.click(newButton[0]); 44 | 45 | expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); 46 | expect(screen.getByLabelText("go to older budget")).toBeInTheDocument(); 47 | expect(screen.getByLabelText("go to newer budget")).toBeInTheDocument(); 48 | }); 49 | 50 | it.skip("triggers event when back/fwd buttons are pressed", async () => { 51 | render(comp); 52 | await userEvent.click(screen.getByLabelText("go to older budget")); 53 | 54 | await userEvent.click(screen.getByLabelText("go to newer budget")); 55 | }); 56 | 57 | it.skip("triggers event when back/fwd shortcuts are pressed", async () => { 58 | render(comp); 59 | await userEvent.type(screen.getByTestId("header"), "{pagedown}"); 60 | 61 | await userEvent.type(screen.getByTestId("header"), "{home}"); 62 | 63 | await userEvent.type(screen.getByTestId("header"), "{pageup}"); 64 | }); 65 | 66 | it.skip("triggers event when clone button is pressed", async () => { 67 | render(comp); 68 | setBudgetMock.mockClear(); 69 | await userEvent.click(screen.getByLabelText("clone budget")); 70 | expect(setBudgetMock).toHaveBeenCalledWith( 71 | BudgetMother.testBudgetClone(), 72 | true, 73 | ); 74 | }); 75 | 76 | it("triggers event when import button is pressed", async () => { 77 | render(comp); 78 | await userEvent.click(screen.getByLabelText("import or export budget")); 79 | const uploadEl = screen.getByTestId("import-form-control"); 80 | await userEvent.upload( 81 | uploadEl, 82 | new File([JSON.stringify(BudgetMother.testBudget())], "budget", { 83 | type: "application/json", 84 | }), 85 | ); 86 | expect((uploadEl as HTMLInputElement).files).toHaveLength(1); 87 | }); 88 | 89 | it("triggers event when export shortcuts are pressed", async () => { 90 | render(comp); 91 | await userEvent.type(screen.getByTestId("header"), "o"); 92 | expect( 93 | screen.getByRole("button", { 94 | name: importRegex, 95 | }), 96 | ).toBeInTheDocument(); 97 | expect( 98 | screen.getByRole("button", { 99 | name: exportCsvRegex, 100 | }), 101 | ).toBeInTheDocument(); 102 | expect( 103 | screen.getByRole("button", { 104 | name: exportJsonRegex, 105 | }), 106 | ).toBeInTheDocument(); 107 | 108 | await userEvent.type(screen.getByTestId("header"), "s"); 109 | 110 | await userEvent.type(screen.getByTestId("header"), "d"); 111 | }); 112 | 113 | it("triggers event when settings shortcuts are pressed", async () => { 114 | render(comp); 115 | await userEvent.type(screen.getByTestId("header"), "t"); 116 | expect( 117 | screen.getByRole("link", { 118 | name: changelogRegex, 119 | }), 120 | ).toBeInTheDocument(); 121 | }); 122 | 123 | it("triggers event when user changes budget name input", async () => { 124 | render(comp); 125 | setBudgetMock.mockClear(); 126 | await userEvent.type(screen.getByDisplayValue("2023-03"), "change name"); 127 | 128 | expect(screen.getByDisplayValue("2023-03change name")).toBeInTheDocument(); 129 | expect(setBudgetMock).toHaveBeenCalledWith( 130 | { ...BudgetMother.testBudget(), name: "2023-03change name" }, 131 | false, 132 | ); 133 | }); 134 | 135 | it.skip("triggers event when user clicks delete budget button", async () => { 136 | render(comp); 137 | 138 | await waitFor(async () => { 139 | await userEvent.click(screen.getByLabelText("delete budget")); 140 | await userEvent.click( 141 | screen.getByRole("button", { name: "confirm budget deletion" }), 142 | ); 143 | }); 144 | }); 145 | 146 | it("opens instructions in new tab", async () => { 147 | render(comp); 148 | const instructionsButton = screen.getByLabelText( 149 | "open instructions in new tab", 150 | ); 151 | await userEvent.click(instructionsButton); 152 | expect(windowSpy).toHaveBeenCalled(); 153 | }); 154 | 155 | it("opens guitos repo in new tab", async () => { 156 | budgetContextSpy.mockReturnValue(testEmptyBudgetContext); 157 | render(comp); 158 | const guitosButton = screen.getByLabelText("open guitos repository"); 159 | await userEvent.click(guitosButton); 160 | expect(guitosButton).toHaveAttribute( 161 | "href", 162 | "https://github.com/rare-magma/guitos", 163 | ); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/NavBarDelete.tsx: -------------------------------------------------------------------------------- 1 | import type { RefObject } from "react"; 2 | import { Button, Nav, OverlayTrigger, Popover, Tooltip } from "react-bootstrap"; 3 | import { BsXLg } from "react-icons/bs"; 4 | import { useBudget } from "../../context/BudgetContext"; 5 | import type { Uuid } from "../../domain/uuid"; 6 | 7 | interface NavBarDeleteProps { 8 | deleteButtonRef: RefObject; 9 | handleRemove: (i: Uuid) => void; 10 | expanded: boolean; 11 | } 12 | 13 | export function NavBarDelete({ 14 | deleteButtonRef, 15 | handleRemove, 16 | expanded, 17 | }: NavBarDeleteProps) { 18 | const { budget } = useBudget(); 19 | 20 | return ( 21 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/NavBarItem.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from "react"; 2 | import { Button, Nav, OverlayTrigger, Tooltip } from "react-bootstrap"; 3 | import "./NavBar.css"; 4 | 5 | interface NavItemProps { 6 | itemClassName: string; 7 | onClick: () => void; 8 | tooltipID: string; 9 | tooltipText: string; 10 | buttonAriaLabel: string; 11 | buttonClassName?: string; 12 | buttonVariant: string; 13 | buttonIcon: ReactNode; 14 | disabled?: boolean; 15 | } 16 | 17 | export function NavBarItem({ 18 | itemClassName, 19 | onClick, 20 | tooltipID, 21 | tooltipText, 22 | buttonAriaLabel, 23 | buttonClassName, 24 | buttonVariant, 25 | buttonIcon, 26 | disabled, 27 | }: NavItemProps) { 28 | return ( 29 | 30 | 35 | {tooltipText} 36 | 37 | } 38 | > 39 | 47 | 48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/NavBarSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { 3 | Button, 4 | InputGroup, 5 | Nav, 6 | OverlayTrigger, 7 | Popover, 8 | Stack, 9 | Tooltip, 10 | } from "react-bootstrap"; 11 | import { Typeahead } from "react-bootstrap-typeahead"; 12 | import type { Option } from "react-bootstrap-typeahead/types/types"; 13 | import { useHotkeys } from "react-hotkeys-hook"; 14 | import { BsGear } from "react-icons/bs"; 15 | import { currenciesList } from "../../../lists/currenciesList"; 16 | import { useConfig } from "../../context/ConfigContext"; 17 | import { UserOptions } from "../../domain/userOptions"; 18 | import { useDB } from "../../hooks/useDB"; 19 | 20 | interface NavBarSettingsProps { 21 | expanded: boolean; 22 | } 23 | 24 | export function NavBarSettings({ expanded }: NavBarSettingsProps) { 25 | const { userOptions, setUserOptions } = useConfig(); 26 | const { saveCurrencyOption } = useDB(); 27 | const settingsButtonRef = useRef(null); 28 | const versionRef = useRef(null); 29 | 30 | useHotkeys("t", (e) => !e.repeat && settingsButtonRef.current?.click(), { 31 | preventDefault: true, 32 | }); 33 | 34 | return ( 35 | 130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/guitos/sections/NavBar/__snapshots__/NavBar.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`NavBar > matches snapshot 1`] = ` 4 | 5 | 6 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/guitos/sections/Notification/Notification.css: -------------------------------------------------------------------------------- 1 | .toast-container > :not(:last-child) { 2 | margin-bottom: 0.75rem; 3 | } 4 | 5 | .toast { 6 | background: var(--lightbgcolor); 7 | border: 1px solid var(--lightbgcolor); 8 | color: var(--textcolor); 9 | } 10 | -------------------------------------------------------------------------------- /src/guitos/sections/Notification/Notification.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { describe, expect, it, vi } from "vitest"; 4 | import { undoMock } from "../../../setupTests"; 5 | import type { BudgetNotification } from "../../context/GeneralContext"; 6 | import { Notification } from "./Notification"; 7 | 8 | describe("Notification", () => { 9 | const notification: BudgetNotification = { 10 | show: true, 11 | id: "a", 12 | body: "notification body", 13 | showUndo: true, 14 | }; 15 | 16 | const handleClose = vi.fn(); 17 | const comp = ( 18 | 19 | ); 20 | 21 | it("matches snapshot", () => { 22 | render(comp); 23 | expect(comp).toMatchSnapshot(); 24 | }); 25 | 26 | it("renders initial state", () => { 27 | render(comp); 28 | expect(screen.getByText("notification body")).toBeInTheDocument(); 29 | }); 30 | 31 | it("closes when close button is clicked", async () => { 32 | render(comp); 33 | await userEvent.click( 34 | screen.getByRole("button", { 35 | name: "dismiss notification", 36 | }), 37 | ); 38 | expect(handleClose).toHaveBeenCalled(); 39 | }); 40 | 41 | it("closes when undo button is clicked", async () => { 42 | render(comp); 43 | undoMock.mockClear(); 44 | await userEvent.click( 45 | screen.getByRole("button", { 46 | name: "undo budget deletion", 47 | }), 48 | ); 49 | expect(undoMock).toHaveBeenCalled(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/guitos/sections/Notification/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Toast } from "react-bootstrap"; 2 | import { BsArrowCounterclockwise, BsX } from "react-icons/bs"; 3 | import { useBudget } from "../../context/BudgetContext"; 4 | import type { BudgetNotification } from "../../context/GeneralContext"; 5 | import "./Notification.css"; 6 | 7 | interface NotificationProps { 8 | notification: BudgetNotification; 9 | handleClose: (notification: BudgetNotification) => void; 10 | } 11 | 12 | export function Notification({ notification, handleClose }: NotificationProps) { 13 | const { undo } = useBudget(); 14 | 15 | return ( 16 | handleClose(notification)} 19 | show={notification.show} 20 | autohide={true} 21 | delay={notification.showUndo ? 60000 : 3000} 22 | > 23 | 27 |
36 | {notification.body} 37 |
38 | {notification.showUndo && ( 39 | 52 | )} 53 | 63 |
64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/guitos/sections/Notification/__snapshots__/Notification.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`Notification > matches snapshot 1`] = ` 4 | 15 | `; 16 | -------------------------------------------------------------------------------- /src/guitos/sections/StatCard/StatCard.css: -------------------------------------------------------------------------------- 1 | .auto-goal { 2 | border: 1px solid var(--lightbgcolor); 3 | border-radius: 0.375rem; 4 | } 5 | 6 | .progress { 7 | background-color: var(--comment); 8 | height: 3px; 9 | } 10 | 11 | .progress-bar { 12 | background-color: var(--pink); 13 | } 14 | -------------------------------------------------------------------------------- /src/guitos/sections/StatCard/StatCard.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { vi } from "vitest"; 4 | import { describe, expect, it } from "vitest"; 5 | import { setBudgetMock } from "../../../setupTests"; 6 | import { BudgetMother } from "../../domain/budget.mother"; 7 | import { StatCard } from "./StatCard"; 8 | 9 | describe("StatCard", () => { 10 | const onShowGraphs = vi.fn(); 11 | const comp = ; 12 | 13 | it("matches snapshot", () => { 14 | render(comp); 15 | expect(comp).toMatchSnapshot(); 16 | }); 17 | 18 | it("renders initial state", () => { 19 | render(comp); 20 | expect(screen.getByText("Statistics")).toBeInTheDocument(); 21 | expect(screen.getByDisplayValue("$90")).toBeInTheDocument(); 22 | expect(screen.getByDisplayValue("$80")).toBeInTheDocument(); 23 | expect(screen.getByDisplayValue("$10")).toBeInTheDocument(); 24 | expect(screen.getByDisplayValue("10")).toBeInTheDocument(); 25 | }); 26 | 27 | it("triggers onChange when user changes input", async () => { 28 | render(comp); 29 | setBudgetMock.mockClear(); 30 | await userEvent.type(screen.getByLabelText("reserves"), "2"); 31 | 32 | expect(setBudgetMock).toHaveBeenCalledWith( 33 | { 34 | ...BudgetMother.testBudget(), 35 | stats: { ...BudgetMother.testBudget().stats, reserves: 2 }, 36 | }, 37 | false, 38 | ); 39 | expect(screen.getByDisplayValue("$2")).toBeInTheDocument(); 40 | 41 | await userEvent.clear(screen.getByTestId("goal-input")); 42 | await userEvent.type(screen.getByTestId("goal-input"), "95"); 43 | expect(setBudgetMock).toHaveBeenCalledWith( 44 | { 45 | ...BudgetMother.testBudget(), 46 | stats: { 47 | ...BudgetMother.testBudget().stats, 48 | goal: 95, 49 | saved: 95, 50 | withGoal: -5, 51 | }, 52 | }, 53 | false, 54 | ); 55 | 56 | expect(screen.getByDisplayValue("95")).toBeInTheDocument(); 57 | }); 58 | 59 | it("triggers onAutoGoal when user clicks button", async () => { 60 | render(comp); 61 | setBudgetMock.mockClear(); 62 | await userEvent.click( 63 | screen.getByRole("button", { name: "calculate savings goal" }), 64 | ); 65 | expect(setBudgetMock).toHaveBeenCalledWith( 66 | { 67 | ...BudgetMother.testBudget(), 68 | stats: { 69 | ...BudgetMother.testBudget().stats, 70 | goal: 90, 71 | saved: 90, 72 | withGoal: 0, 73 | }, 74 | }, 75 | true, 76 | ); 77 | }); 78 | 79 | it("triggers onShowGraphs when user clicks button", async () => { 80 | render(comp); 81 | 82 | await userEvent.click( 83 | screen.getByRole("button", { name: "open charts view" }), 84 | ); 85 | 86 | expect(onShowGraphs).toHaveBeenCalledTimes(1); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/guitos/sections/StatCard/__snapshots__/StatCard.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`StatCard > matches snapshot 1`] = ` 4 | 7 | `; 8 | -------------------------------------------------------------------------------- /src/guitos/sections/TableCard/TableCard.css: -------------------------------------------------------------------------------- 1 | .btn-expenses-plus-button { 2 | background-color: var(--bgcolor); 3 | border: 1px solid var(--lightbgcolor); 4 | color: var(--purple); 5 | } 6 | 7 | .btn-expenses-plus-button:focus-visible, 8 | .btn-expenses-plus-button:hover { 9 | background-color: var(--purple); 10 | border: 1px solid var(--purple); 11 | color: var(--lightbgcolor); 12 | } 13 | 14 | .btn-revenue-plus-button { 15 | background-color: var(--bgcolor); 16 | border: 1px solid var(--lightbgcolor); 17 | color: var(--highlight); 18 | } 19 | 20 | .btn-revenue-plus-button:focus-visible, 21 | .btn-revenue-plus-button:hover { 22 | background-color: var(--highlight); 23 | border: 1px solid var(--highlight); 24 | color: var(--lightbgcolor); 25 | } 26 | 27 | .expenses-card, 28 | .revenue-card { 29 | border: 1px solid var(--lightbgcolor); 30 | } 31 | 32 | .expenses-card-header, 33 | .revenue-card-header { 34 | border-bottom: 1px solid var(--comment); 35 | } 36 | 37 | .btn-check:checked + .btn, 38 | .btn.active, 39 | .btn.show { 40 | box-shadow: none !important; 41 | background-color: var(--cyan); 42 | border: 1px solid var(--cyan); 43 | border-radius: 0.375rem; 44 | color: var(--lightbgcolor); 45 | } 46 | 47 | .btn-check + .btn:hover { 48 | box-shadow: none !important; 49 | border: 1px solid var(--cyan); 50 | } 51 | 52 | .btn-check:focus-visible + .btn, 53 | .btn-check { 54 | box-shadow: none !important; 55 | color: var(--cyan); 56 | border: 1px solid var(--cyan); 57 | } 58 | 59 | .btn-outline-info.toggle { 60 | background-color: var(--bgcolor); 61 | border: 1px solid var(--lightbgcolor); 62 | border-radius: 0.375rem; 63 | color: var(--cyan); 64 | } 65 | 66 | .btn-outline-info.toggle:focus-visible, 67 | .btn-outline-info.toggle:hover { 68 | background-color: var(--cyan); 69 | border: 1px solid var(--cyan); 70 | border-radius: 0.375rem; 71 | color: var(--lightbgcolor); 72 | } 73 | 74 | .btn-outline-info.toggle:disabled { 75 | background-color: var(--lightbgcolor); 76 | border: 1px solid var(--lightbgcolor); 77 | border-radius: 0.375rem; 78 | color: var(--comment); 79 | } 80 | -------------------------------------------------------------------------------- /src/guitos/sections/TableCard/TableCard.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen } from "@testing-library/react"; 2 | import userEvent from "@testing-library/user-event"; 3 | import { BrowserRouter } from "react-router"; 4 | import { describe, expect, it } from "vitest"; 5 | import { setBudgetMock } from "../../../setupTests"; 6 | import { BudgetMother } from "../../domain/budget.mother"; 7 | import TableCard from "./TableCard"; 8 | 9 | describe("TableCard", () => { 10 | const comp = ( 11 | 12 | 13 | 14 | ); 15 | 16 | it("matches snapshot", () => { 17 | render(comp); 18 | expect(comp).toMatchSnapshot(); 19 | }); 20 | it("renders initial Expenses state", async () => { 21 | render(comp); 22 | expect(await screen.findByDisplayValue("expense1")).toBeInTheDocument(); 23 | expect(screen.getByDisplayValue("$10")).toBeInTheDocument(); 24 | }); 25 | 26 | it("renders initial Revenue state", async () => { 27 | render( 28 | 29 | 30 | , 31 | ); 32 | expect(await screen.findByDisplayValue("income1")).toBeInTheDocument(); 33 | expect(await screen.findByDisplayValue("$100")).toBeInTheDocument(); 34 | }); 35 | 36 | it("responds when user changes input", async () => { 37 | render(comp); 38 | await userEvent.type(screen.getByDisplayValue("expense1"), "change name"); 39 | expect(screen.getByDisplayValue("expense1change name")).toBeInTheDocument(); 40 | 41 | expect(setBudgetMock).toHaveBeenCalledWith( 42 | { 43 | ...BudgetMother.testBudget(), 44 | expenses: { 45 | items: [{ id: 1, name: "expense1change name", value: 10 }], 46 | total: 10, 47 | }, 48 | }, 49 | false, 50 | ); 51 | setBudgetMock.mockClear(); 52 | 53 | await userEvent.type(screen.getByDisplayValue("$10"), "123"); 54 | 55 | expect(screen.getByDisplayValue("$123")).toBeInTheDocument(); 56 | expect(setBudgetMock).toHaveBeenCalledWith( 57 | { 58 | ...BudgetMother.testBudget(), 59 | expenses: { 60 | items: [{ id: 1, name: "expense1", value: 123 }], 61 | total: 123, 62 | }, 63 | stats: { 64 | ...BudgetMother.testBudget().stats, 65 | available: -23, 66 | withGoal: -33, 67 | }, 68 | }, 69 | false, 70 | ); 71 | }); 72 | 73 | it("adds new Expense when user clicks adds new item button", async () => { 74 | render(comp); 75 | await userEvent.click( 76 | screen.getByRole("button", { name: "add item to Expenses" }), 77 | ); 78 | expect(screen.getByDisplayValue("$10")).toBeInTheDocument(); 79 | expect(setBudgetMock).toHaveBeenCalledWith( 80 | { 81 | ...BudgetMother.testBudget(), 82 | expenses: { 83 | items: [ 84 | ...BudgetMother.testBudget().expenses.items, 85 | { id: 2, name: "", value: 0 }, 86 | ], 87 | total: 10, 88 | }, 89 | }, 90 | true, 91 | ); 92 | }); 93 | 94 | it("adds new Revenue when user clicks adds new item button", async () => { 95 | cleanup(); 96 | render( 97 | 98 | 99 | , 100 | ); 101 | await userEvent.click( 102 | screen.getByRole("button", { name: "add item to Revenue" }), 103 | ); 104 | expect(screen.getByDisplayValue("$100")).toBeInTheDocument(); 105 | expect(setBudgetMock).toHaveBeenCalledWith( 106 | { 107 | ...BudgetMother.testBudget(), 108 | incomes: { 109 | items: [ 110 | ...BudgetMother.testBudget().incomes.items, 111 | { id: 3, name: "", value: 0 }, 112 | ], 113 | total: 100, 114 | }, 115 | }, 116 | true, 117 | ); 118 | }); 119 | 120 | it("removes item when user clicks delete item button", async () => { 121 | render(comp); 122 | await userEvent.click( 123 | screen.getByRole("button", { name: "delete item 1" }), 124 | ); 125 | await userEvent.click( 126 | screen.getByRole("button", { name: "confirm item 1 deletion" }), 127 | ); 128 | expect(setBudgetMock).toHaveBeenCalledWith( 129 | { 130 | ...BudgetMother.testBudget(), 131 | expenses: { 132 | items: [], 133 | total: 0, 134 | }, 135 | stats: { 136 | ...BudgetMother.testBudget().stats, 137 | available: 100, 138 | withGoal: 90, 139 | }, 140 | }, 141 | true, 142 | ); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/guitos/sections/TableCard/__snapshots__/TableCard.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`TableCard > matches snapshot 1`] = ` 4 | 5 | 8 | 9 | `; 10 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: system-ui, sans-serif; 3 | margin: 0; 4 | } 5 | 6 | code { 7 | font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, 8 | "DejaVu Sans Mono", monospace; 9 | } 10 | 11 | html { 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerSW } from "virtual:pwa-register"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import "./index.css"; 5 | import { App } from "./App"; 6 | 7 | const updateSW = registerSW({ 8 | onNeedRefresh() { 9 | updateSW(true); 10 | }, 11 | }); 12 | 13 | if (window.location.pathname === "/") { 14 | const lastOpenedBudget = localStorage.getItem("guitos_lastOpenedBudget"); 15 | if (lastOpenedBudget) { 16 | window.location.pathname = `/${lastOpenedBudget}`; 17 | } 18 | } 19 | 20 | const rootElement = document.getElementById("root"); 21 | const root = rootElement && ReactDOM.createRoot(rootElement); 22 | 23 | root?.render( 24 | 25 | 26 | , 27 | ); 28 | -------------------------------------------------------------------------------- /src/lists/chromeLocalesList.ts: -------------------------------------------------------------------------------- 1 | export const chromeLocalesList = [ 2 | "af", 3 | "am", 4 | "ar", 5 | "as", 6 | "az", 7 | "be", 8 | "bg", 9 | "bn", 10 | "bs", 11 | "ca", 12 | "cs", 13 | "cy", 14 | "da", 15 | "de", 16 | "el", 17 | "en-GB", 18 | "en-US", 19 | "es", 20 | "es-419", 21 | "et", 22 | "eu", 23 | "fa", 24 | "fi", 25 | "fil", 26 | "fr", 27 | "fr-CA", 28 | "gl", 29 | "gu", 30 | "he", 31 | "hi", 32 | "hr", 33 | "hu", 34 | "hy", 35 | "id", 36 | "is", 37 | "it", 38 | "ja", 39 | "ka", 40 | "kk", 41 | "km", 42 | "kn", 43 | "ko", 44 | "ky", 45 | "lo", 46 | "lt", 47 | "lv", 48 | "mk", 49 | "ml", 50 | "mn", 51 | "mr", 52 | "ms", 53 | "my", 54 | "nb", 55 | "ne", 56 | "nl", 57 | "or", 58 | "pa", 59 | "pl", 60 | "pt-BR", 61 | "pt-PT", 62 | "ro", 63 | "ru", 64 | "si", 65 | "sk", 66 | "sl", 67 | "sq", 68 | "sr", 69 | "sr-Latn", 70 | "sv", 71 | "sw", 72 | "ta", 73 | "te", 74 | "th", 75 | "tr", 76 | "uk", 77 | "ur", 78 | "uz", 79 | "vi", 80 | "zh-CN", 81 | "zh-HK", 82 | "zh-TW", 83 | "zu", 84 | ]; 85 | -------------------------------------------------------------------------------- /src/lists/currenciesList.ts: -------------------------------------------------------------------------------- 1 | export const currenciesList = [ 2 | "AED", 3 | "AFN", 4 | "ALL", 5 | "AMD", 6 | "ANG", 7 | "AOA", 8 | "ARS", 9 | "AUD", 10 | "AWG", 11 | "AZN", 12 | "BAM", 13 | "BBD", 14 | "BDT", 15 | "BGN", 16 | "BHD", 17 | "BIF", 18 | "BMD", 19 | "BND", 20 | "BOB", 21 | "BRL", 22 | "BSD", 23 | "BTN", 24 | "BWP", 25 | "BYN", 26 | "BZD", 27 | "CAD", 28 | "CDF", 29 | "CHF", 30 | "CLP", 31 | "CNY", 32 | "COP", 33 | "CRC", 34 | "CUC", 35 | "CUP", 36 | "CVE", 37 | "CZK", 38 | "DJF", 39 | "DKK", 40 | "DOP", 41 | "DZD", 42 | "EGP", 43 | "ERN", 44 | "ETB", 45 | "EUR", 46 | "FJD", 47 | "FKP", 48 | "GBP", 49 | "GEL", 50 | "GHS", 51 | "GIP", 52 | "GMD", 53 | "GNF", 54 | "GTQ", 55 | "GYD", 56 | "HKD", 57 | "HNL", 58 | "HTG", 59 | "HUF", 60 | "IDR", 61 | "ILS", 62 | "INR", 63 | "IQD", 64 | "IRR", 65 | "ISK", 66 | "JMD", 67 | "JOD", 68 | "JPY", 69 | "KES", 70 | "KGS", 71 | "KHR", 72 | "KMF", 73 | "KPW", 74 | "KRW", 75 | "KWD", 76 | "KYD", 77 | "KZT", 78 | "LAK", 79 | "LBP", 80 | "LKR", 81 | "LRD", 82 | "LSL", 83 | "LYD", 84 | "MAD", 85 | "MDL", 86 | "MGA", 87 | "MKD", 88 | "MMK", 89 | "MNT", 90 | "MOP", 91 | "MRU", 92 | "MUR", 93 | "MVR", 94 | "MWK", 95 | "MXN", 96 | "MYR", 97 | "MZN", 98 | "NAD", 99 | "NGN", 100 | "NIO", 101 | "NOK", 102 | "NPR", 103 | "NZD", 104 | "OMR", 105 | "PAB", 106 | "PEN", 107 | "PGK", 108 | "PHP", 109 | "PKR", 110 | "PLN", 111 | "PYG", 112 | "QAR", 113 | "RON", 114 | "RSD", 115 | "RUB", 116 | "RWF", 117 | "SAR", 118 | "SBD", 119 | "SCR", 120 | "SDG", 121 | "SEK", 122 | "SGD", 123 | "SHP", 124 | "SLL", 125 | "SOS", 126 | "SRD", 127 | "SSP", 128 | "STN", 129 | "SVC", 130 | "SYP", 131 | "SZL", 132 | "THB", 133 | "TJS", 134 | "TMT", 135 | "TND", 136 | "TOP", 137 | "TRY", 138 | "TTD", 139 | "TWD", 140 | "TZS", 141 | "UAH", 142 | "UGX", 143 | "USD", 144 | "UYU", 145 | "UZS", 146 | "VES", 147 | "VND", 148 | "VUV", 149 | "WST", 150 | "XAF", 151 | "XCD", 152 | "XOF", 153 | "XPF", 154 | "YER", 155 | "ZAR", 156 | "ZMW", 157 | "ZWL", 158 | ]; 159 | -------------------------------------------------------------------------------- /src/lists/currenciesMap.ts: -------------------------------------------------------------------------------- 1 | export const currenciesMap = { 2 | // Latin American Spanish locale 3 | 419: "USD", 4 | AD: "EUR", 5 | AE: "AED", 6 | AF: "AFN", 7 | AG: "XCD", 8 | AI: "XCD", 9 | AL: "ALL", 10 | AM: "AMD", 11 | // Aragonés locale 12 | AN: "EUR", 13 | AO: "AOA", 14 | AQ: "USD", 15 | AR: "ARS", 16 | AS: "USD", 17 | // Asturian locale 18 | AST: "EUR", 19 | AT: "EUR", 20 | AU: "AUD", 21 | AW: "AWG", 22 | AX: "EUR", 23 | AZ: "AZN", 24 | BA: "BAM", 25 | BB: "BBD", 26 | BD: "BDT", 27 | BE: "EUR", 28 | BF: "XOF", 29 | BG: "BGN", 30 | BH: "BHD", 31 | BI: "BIF", 32 | BJ: "XOF", 33 | BL: "EUR", 34 | BM: "BMD", 35 | BN: "BND", 36 | BO: "BOB", 37 | BQ: "USD", 38 | BR: "BRL", 39 | BS: "BSD", 40 | BT: "BTN", 41 | BV: "NOK", 42 | BW: "BWP", 43 | BY: "BYN", 44 | BZ: "BZD", 45 | CA: "CAD", 46 | CC: "AUD", 47 | CD: "CDF", 48 | CF: "XAF", 49 | CG: "CDF", 50 | CH: "CHF", 51 | CI: "XOF", 52 | CK: "NZD", 53 | CL: "CLP", 54 | CM: "XAF", 55 | CN: "CNY", 56 | CO: "COP", 57 | CR: "CRC", 58 | CU: "CUP", 59 | CV: "CVE", 60 | CW: "ANG", 61 | CX: "AUD", 62 | CY: "EUR", 63 | CS: "CZK", 64 | CZ: "CZK", 65 | DE: "EUR", 66 | DJ: "DJF", 67 | // Danish locale 68 | DA: "DKK", 69 | DK: "DKK", 70 | DM: "XCD", 71 | DO: "DOP", 72 | DZ: "DZD", 73 | EC: "USD", 74 | EE: "EUR", 75 | EG: "EGP", 76 | EH: "MAD", 77 | // Greek locale 78 | EL: "EUR", 79 | ER: "ERN", 80 | ES: "EUR", 81 | ET: "ETB", 82 | // Basque locale 83 | EU: "EUR", 84 | // Persian locale 85 | FA: "IRR", 86 | FI: "EUR", 87 | // Philippines locale 88 | FIL: "PHP", 89 | FJ: "FJD", 90 | FK: "FKP", 91 | FM: "USD", 92 | FO: "DKK", 93 | FR: "EUR", 94 | GA: "XAF", 95 | GB: "GBP", 96 | GD: "XCD", 97 | GE: "GEL", 98 | GF: "EUR", 99 | GG: "GBP", 100 | GH: "GHS", 101 | GI: "GIP", 102 | GL: "DKK", 103 | GM: "GMD", 104 | GN: "GNF", 105 | GP: "EUR", 106 | GQ: "XAF", 107 | GR: "EUR", 108 | GS: "GBP", 109 | GT: "GTQ", 110 | GU: "USD", 111 | GW: "XOF", 112 | GY: "GYD", 113 | // Hebrew locale 114 | HE: "ILS", 115 | // Hindi locale 116 | HI: "INR", 117 | HK: "HKD", 118 | HM: "AUD", 119 | HN: "HNL", 120 | HR: "EUR", 121 | HT: "HTG", 122 | HU: "HUF", 123 | ID: "IDR", 124 | IE: "EUR", 125 | IL: "ILS", 126 | IM: "GBP", 127 | IN: "INR", 128 | IO: "USD", 129 | IQ: "IQD", 130 | IR: "IRR", 131 | IS: "ISK", 132 | IT: "EUR", 133 | // Japanese locale 134 | JA: "JPY", 135 | JE: "GBP", 136 | JM: "JMD", 137 | JO: "JOD", 138 | JP: "JPY", 139 | KA: "GEL", 140 | KE: "KES", 141 | KG: "KGS", 142 | KH: "KHR", 143 | KI: "AUD", 144 | // Kazhak locale 145 | KK: "KZT", 146 | KM: "KMF", 147 | KN: "XCD", 148 | // Korean locale 149 | KO: "KRW", 150 | KP: "KPW", 151 | KR: "KRW", 152 | KW: "KWD", 153 | KY: "KYD", 154 | KZ: "KZT", 155 | LA: "LAK", 156 | LB: "LBP", 157 | LC: "XCD", 158 | LI: "CHF", 159 | LK: "LKR", 160 | // Laos locale 161 | LO: "LAK", 162 | LR: "LRD", 163 | LS: "LSL", 164 | LT: "EUR", 165 | LU: "EUR", 166 | LV: "EUR", 167 | LY: "LYD", 168 | MA: "MAD", 169 | MC: "EUR", 170 | MD: "MDL", 171 | ME: "EUR", 172 | MF: "EUR", 173 | MG: "MGA", 174 | MH: "USD", 175 | MK: "MKD", 176 | ML: "XOF", 177 | MM: "MMK", 178 | MN: "MNT", 179 | MO: "MOP", 180 | MP: "USD", 181 | MQ: "EUR", 182 | MR: "MRU", 183 | MS: "XCD", 184 | MT: "EUR", 185 | MU: "MUR", 186 | MV: "MVR", 187 | MW: "MWK", 188 | MX: "MXN", 189 | MY: "MYR", 190 | MZ: "MZN", 191 | NA: "NAD", 192 | // Norway locale 193 | NB: "NOK", 194 | NC: "XPF", 195 | NE: "XOF", 196 | NF: "AUD", 197 | NG: "NGN", 198 | NI: "NIO", 199 | NL: "EUR", 200 | NO: "NOK", 201 | NP: "NPR", 202 | NR: "AUD", 203 | NU: "NZD", 204 | NZ: "NZD", 205 | OM: "OMR", 206 | PA: "PAB", 207 | PE: "PEN", 208 | PF: "XPF", 209 | PG: "PGK", 210 | PH: "PHP", 211 | PK: "PKR", 212 | PL: "PLN", 213 | PM: "EUR", 214 | PN: "NZD", 215 | PR: "USD", 216 | PS: "ILS", 217 | PT: "EUR", 218 | PW: "USD", 219 | PY: "PYG", 220 | QA: "QAR", 221 | RE: "EUR", 222 | RO: "RON", 223 | RS: "RSD", 224 | RU: "RUB", 225 | RW: "RWF", 226 | SA: "SAR", 227 | SB: "SBD", 228 | SC: "SCR", 229 | SD: "SDG", 230 | SE: "SEK", 231 | SG: "SGD", 232 | SH: "SHP", 233 | SI: "EUR", 234 | SJ: "NOK", 235 | SK: "EUR", 236 | SL: "SLL", 237 | SM: "EUR", 238 | SN: "XOF", 239 | SO: "SOS", 240 | SR: "SRD", 241 | SS: "SSP", 242 | ST: "STN", 243 | SV: "SVC", 244 | SX: "ANG", 245 | SY: "SYP", 246 | SZ: "SZL", 247 | // Telugu locale 248 | TE: "INR", 249 | TC: "USD", 250 | TD: "XAF", 251 | TF: "EUR", 252 | TG: "XOF", 253 | TH: "THB", 254 | TJ: "TJS", 255 | TK: "NZD", 256 | TL: "USD", 257 | TM: "TMT", 258 | TN: "TND", 259 | TO: "TOP", 260 | TR: "TRY", 261 | TT: "TTD", 262 | TV: "AUD", 263 | TW: "TWD", 264 | TZ: "TZS", 265 | UA: "UAH", 266 | UG: "UGX", 267 | UM: "USD", 268 | US: "USD", 269 | UY: "UYU", 270 | UZ: "UZS", 271 | VA: "EUR", 272 | VC: "XCD", 273 | VE: "VES", 274 | VG: "USD", 275 | VI: "USD", 276 | VN: "VND", 277 | VU: "VUV", 278 | WF: "XPF", 279 | WS: "WST", 280 | YE: "YER", 281 | YT: "EUR", 282 | ZA: "ZAR", 283 | ZM: "ZMW", 284 | ZW: "ZWL", 285 | }; 286 | -------------------------------------------------------------------------------- /src/lists/firefoxLocalesList.ts: -------------------------------------------------------------------------------- 1 | export const firefoxLocalesList = [ 2 | "ach", 3 | "af", 4 | "an", 5 | "ar", 6 | "ast", 7 | "az", 8 | "be", 9 | "bg", 10 | "bn", 11 | "bo", 12 | "br", 13 | "brx", 14 | "bs", 15 | "ca", 16 | "ca-valencia", 17 | "cak", 18 | "ckb", 19 | "cs", 20 | "cy", 21 | "da", 22 | "de", 23 | "dsb", 24 | "el", 25 | "en-CA", 26 | "en-GB", 27 | "en-US", 28 | "eo", 29 | "es-AR", 30 | "es-CL", 31 | "es-ES", 32 | "es-MX", 33 | "et", 34 | "eu", 35 | "fa", 36 | "ff", 37 | "fi", 38 | "fr", 39 | "fur", 40 | "fy-NL", 41 | "ga-IE", 42 | "gd", 43 | "gl", 44 | "gn", 45 | "gu-IN", 46 | "he", 47 | "hi-IN", 48 | "hr", 49 | "hsb", 50 | "hu", 51 | "hy-AM", 52 | "hye", 53 | "ia", 54 | "id", 55 | "is", 56 | "it", 57 | "ja", 58 | "ja-JP-mac", 59 | "ka", 60 | "kab", 61 | "kk", 62 | "km", 63 | "kn", 64 | "ko", 65 | "lij", 66 | "lo", 67 | "lt", 68 | "ltg", 69 | "lv", 70 | "meh", 71 | "mix", 72 | "mk", 73 | "ml", 74 | "mr", 75 | "ms", 76 | "my", 77 | "nb-NO", 78 | "ne-NP", 79 | "nl", 80 | "nn-NO", 81 | "oc", 82 | "pa-IN", 83 | "pl", 84 | "pt-BR", 85 | "pt-PT", 86 | "rm", 87 | "ro", 88 | "ru", 89 | "sat", 90 | "sc", 91 | "scn", 92 | "sco", 93 | "si", 94 | "sk", 95 | "skr", 96 | "sl", 97 | "son", 98 | "sq", 99 | "sr", 100 | "sv-SE", 101 | "szl", 102 | "ta", 103 | "te", 104 | "tg", 105 | "th", 106 | "tl", 107 | "tr", 108 | "trs", 109 | "uk", 110 | "ur", 111 | "uz", 112 | "vi", 113 | "wo", 114 | "xh", 115 | "zam", 116 | "zh-CN", 117 | "zh-TW", 118 | ]; 119 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | import { randomUUID } from "node:crypto"; 7 | import * as matchers from "@testing-library/jest-dom/matchers"; 8 | import { cleanup } from "@testing-library/react"; 9 | import { createElement } from "react"; 10 | import { afterEach, beforeEach, expect, vi } from "vitest"; 11 | import * as AppBudgetContext from "./guitos/context/BudgetContext"; 12 | import type { BudgetContextInterface } from "./guitos/context/BudgetContext"; 13 | import { BudgetMother } from "./guitos/domain/budget.mother"; 14 | 15 | window.crypto.randomUUID = randomUUID; 16 | window.isSecureContext = true; 17 | global.URL.createObjectURL = vi.fn(); 18 | 19 | vi.mock("crypto", () => ({ 20 | randomUUID: () => "035c2de4-00a4-403c-8f0e-f81339be9a4e", 21 | })); 22 | 23 | // silence recharts ResponsiveContainer error 24 | vi.mock("recharts", async (importOriginal) => { 25 | const originalModule = await importOriginal(); 26 | return { 27 | //@ts-ignore 28 | ...originalModule, 29 | ResponsiveContainer: () => createElement("div"), 30 | }; 31 | }); 32 | 33 | // extends Vitest's expect method with methods from react-testing-library 34 | expect.extend(matchers); 35 | 36 | export const budgetContextSpy = vi.spyOn(AppBudgetContext, "useBudget"); 37 | beforeEach(() => { 38 | budgetContextSpy.mockReturnValue(testBudgetContext); 39 | }); 40 | 41 | // runs a cleanup after each test case (e.g. clearing happy-dom) 42 | afterEach(() => { 43 | cleanup(); 44 | }); 45 | 46 | Object.defineProperty(window, "matchMedia", { 47 | writable: true, 48 | value: vi.fn().mockImplementation((query: unknown) => ({ 49 | matches: false, 50 | media: query, 51 | onchange: null, 52 | addListener: vi.fn(), // deprecated 53 | removeListener: vi.fn(), // deprecated 54 | addEventListener: vi.fn(), 55 | removeEventListener: vi.fn(), 56 | dispatchEvent: vi.fn(), 57 | })), 58 | }); 59 | 60 | export const setBudgetMock = vi.fn(); 61 | export const setBudgetListMock = vi.fn(); 62 | export const setBudgetNameListMock = vi.fn(); 63 | export const undoMock = vi.fn(); 64 | export const redoMock = vi.fn(); 65 | export const testEmptyBudgetContext = { 66 | budget: undefined, 67 | setBudget: setBudgetMock, 68 | 69 | budgetList: [], 70 | setBudgetList: setBudgetListMock, 71 | 72 | budgetNameList: [], 73 | setBudgetNameList: setBudgetNameListMock, 74 | 75 | revenuePercentage: 0, 76 | past: [], 77 | future: [], 78 | needReload: true, 79 | undo: undoMock, 80 | redo: redoMock, 81 | canUndo: false, 82 | canRedo: false, 83 | }; 84 | 85 | export const testBudgetContext: BudgetContextInterface = { 86 | budget: BudgetMother.testBudget(), 87 | setBudget: setBudgetMock, 88 | 89 | budgetList: BudgetMother.testBudgetList(), 90 | setBudgetList: setBudgetListMock, 91 | 92 | budgetNameList: BudgetMother.testBudgetNameList(), 93 | setBudgetNameList: setBudgetNameListMock, 94 | 95 | revenuePercentage: 10, 96 | past: [], 97 | future: [], 98 | undo: undoMock, 99 | redo: redoMock, 100 | canUndo: false, 101 | canRedo: false, 102 | }; 103 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import Big from "big.js"; 2 | import { expect, test } from "vitest"; 3 | import type { Budget } from "./guitos/domain/budget"; 4 | import { BudgetMother } from "./guitos/domain/budget.mother"; 5 | import type { ItemOperation } from "./guitos/domain/calculationHistoryItem"; 6 | import { UserOptions } from "./guitos/domain/userOptions"; 7 | import { Uuid } from "./guitos/domain/uuid"; 8 | import { localForageOptionsRepository } from "./guitos/infrastructure/localForageOptionsRepository"; 9 | import type { FilteredItem } from "./guitos/sections/ChartsPage/ChartsPage"; 10 | import { chromeLocalesList } from "./lists/chromeLocalesList"; 11 | import { currenciesMap } from "./lists/currenciesMap"; 12 | import { firefoxLocalesList } from "./lists/firefoxLocalesList"; 13 | import { 14 | calc, 15 | createBudgetNameList, 16 | getLabelKey, 17 | getLabelKeyFilteredItem, 18 | getNestedProperty, 19 | getNestedValues, 20 | intlFormat, 21 | median, 22 | parseLocaleNumber, 23 | roundBig, 24 | } from "./utils"; 25 | 26 | const optionsRepository = new localForageOptionsRepository(); 27 | 28 | test("round", () => { 29 | expect(roundBig(Big(123.123123123), 5)).eq(123.12312); 30 | expect(roundBig(Big(123.123), 2)).eq(123.12); 31 | expect(roundBig(Big(1.125324235131234), 2)).eq(1.13); 32 | expect(roundBig(Big(123.124), 2)).eq(123.12); 33 | expect(roundBig(Big(123.125), 2)).eq(123.13); 34 | expect(roundBig(Big(123.125), 1)).eq(123.1); 35 | expect(roundBig(Big(123.126), 0)).eq(123); 36 | }); 37 | 38 | test("createBudgetNameList", () => { 39 | const expectedResult = [ 40 | { 41 | id: Uuid.random().value, 42 | item: "", 43 | name: "2023-03", 44 | }, 45 | { 46 | id: Uuid.random().value, 47 | item: "", 48 | name: "2023-04", 49 | }, 50 | ]; 51 | expect( 52 | createBudgetNameList([ 53 | BudgetMother.testBudget() as Budget, 54 | BudgetMother.testBudget2() as Budget, 55 | ]), 56 | ).toEqual(expectedResult); 57 | expect(createBudgetNameList([])).toEqual([]); 58 | }); 59 | 60 | test("intlFormat", () => { 61 | expect(intlFormat(123.45, new UserOptions("JPY", "ja-JP"))).eq("¥123"); 62 | expect(intlFormat(123.45, new UserOptions("EUR", "en-IE"))).eq("€123.45"); 63 | expect(intlFormat(123.45, new UserOptions("USD", "en-US"))).eq("$123.45"); 64 | expect(intlFormat(123.45, new UserOptions("CAD", "en-CA"))).eq("$123.45"); 65 | expect(intlFormat(123.45, new UserOptions("GBP", "en-GB"))).eq("£123.45"); 66 | expect(intlFormat(123.45, new UserOptions("CNY", "cn-CN"))).eq("CN¥123.45"); 67 | expect(intlFormat(123.45, new UserOptions("AUD", "en-AU"))).eq("$123.45"); 68 | 69 | for (const key in currenciesMap) { 70 | const currencyCode = currenciesMap[ 71 | key as keyof typeof currenciesMap 72 | ] as unknown as string; 73 | 74 | expect(intlFormat(1, new UserOptions(currencyCode, "en-US"))).toBeTruthy(); 75 | } 76 | }); 77 | 78 | test("intlFormat browser locale list", () => { 79 | for (const list of [ 80 | firefoxLocalesList.filter((l) => l !== "ja-JP-mac"), 81 | chromeLocalesList, 82 | ]) { 83 | for (const locale of list) { 84 | const countryCode = optionsRepository.getCountryCode(locale); 85 | const currencyCode = 86 | optionsRepository.getCurrencyCodeFromCountry(countryCode); 87 | expect(intlFormat(1, new UserOptions(currencyCode, locale))).toBeTruthy(); 88 | } 89 | } 90 | }); 91 | 92 | test("parseLocaleNumber", () => { 93 | expect(parseLocaleNumber("123.45", "en-US")).eq(123.45); 94 | expect(parseLocaleNumber("123,45", "es")).eq(123.45); 95 | expect(parseLocaleNumber("12.054.100,55", "de-DE")).eq(12054100.55); 96 | expect(parseLocaleNumber("1,20,54,100.55", "en-IN")).eq(12054100.55); 97 | }); 98 | 99 | test("calc", () => { 100 | expect(calc(123.45, 100, "add")).eq(223.45); 101 | expect(calc(123.45, 100, "subtract")).eq(23.45); 102 | expect(calc(123.45, 100, "multiply")).eq(12345); 103 | expect(calc(123.45, 100, "divide")).eq(1.23); 104 | expect(calc(0, 100, "subtract")).eq(0); 105 | expect(calc(0, 100, "multiply")).eq(0); 106 | expect(calc(0, 100, "divide")).eq(0); 107 | expect(() => calc(0, 100, "sqrt" as ItemOperation)).toThrow(); 108 | }); 109 | 110 | test("median", () => { 111 | expect(median([123.43, 100, 300, -500])).eq(111.715); 112 | expect(median([123.43, 100, 300, 500])).eq(211.715); 113 | expect(median([123.45, 100, 300])).eq(123.45); 114 | expect(median([123.45, 100])).eq(111.725); 115 | expect(median([123.45])).eq(123.45); 116 | expect(median([0])).eq(0); 117 | expect(median([])).eq(0); 118 | expect(median([-1, -2])).eq(-1.5); 119 | }); 120 | 121 | test("getNestedProperty", () => { 122 | expect(getNestedProperty(BudgetMother.testBudget(), "expenses", "total")).eq( 123 | 10, 124 | ); 125 | expect( 126 | getNestedProperty(BudgetMother.testBudget(), "incomes", "items"), 127 | ).toEqual(BudgetMother.testBudget().incomes.items); 128 | }); 129 | 130 | test("getNestedValues", () => { 131 | const expected = BudgetMother.testBudgetList().map((i) => i.expenses.total); 132 | const expected2 = BudgetMother.testBudgetList().map((i) => i.incomes.items); 133 | 134 | expect( 135 | getNestedValues(BudgetMother.testBudgetList(), "expenses", "total"), 136 | ).toEqual(expected); 137 | expect( 138 | getNestedValues(BudgetMother.testBudgetList(), "incomes", "items"), 139 | ).toEqual(expected2); 140 | }); 141 | 142 | test("getLabelKey", () => { 143 | const optionWithoutItem = { 144 | id: "035c2de4-00a4-403c-8f0e-f81339be9a4e", 145 | item: "", 146 | name: "2023-03", 147 | }; 148 | const option = { 149 | id: "035c2de4-00a4-403c-8f0e-f81339be9a4e", 150 | item: "item name", 151 | name: "2023-03", 152 | }; 153 | expect(getLabelKey(optionWithoutItem)).toEqual("2023-03"); 154 | expect(getLabelKey(option)).toEqual("2023-03 item name"); 155 | }); 156 | 157 | test("getLabelKeyFilteredItem", () => { 158 | const optionWithoutItem: FilteredItem = { 159 | id: Uuid.random(), 160 | name: "2023-03", 161 | item: "abcd", 162 | value: 1, 163 | type: "abcd", 164 | }; 165 | expect(getLabelKeyFilteredItem(optionWithoutItem)).toEqual( 166 | "abcd (2023-03 abcd)", 167 | ); 168 | }); 169 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import Big from "big.js"; 2 | import type { RefObject } from "react"; 3 | import type Typeahead from "react-bootstrap-typeahead/types/core/Typeahead"; 4 | import type { NavigateFunction } from "react-router"; 5 | import type { Budget } from "./guitos/domain/budget"; 6 | import type { ItemOperation } from "./guitos/domain/calculationHistoryItem"; 7 | import type { UserOptions } from "./guitos/domain/userOptions"; 8 | import type { FilteredItem } from "./guitos/sections/ChartsPage/ChartsPage"; 9 | import type { SearchOption } from "./guitos/sections/NavBar/NavBar"; 10 | 11 | export function roundBig(number: Big, precision: number): number { 12 | return Big(number).round(precision, 1).toNumber(); 13 | } 14 | 15 | export function calc( 16 | itemValue: number, 17 | change: number, 18 | operation: ItemOperation, 19 | ): number { 20 | let total = 0; 21 | const isActionableChange = !Number.isNaN(itemValue) && change > 0; 22 | 23 | if (!isActionableChange) { 24 | return 0; 25 | } 26 | 27 | let newValue = Big(itemValue); 28 | const changeValue = Big(change); 29 | switch (operation) { 30 | case "add": 31 | newValue = newValue.add(changeValue); 32 | break; 33 | case "subtract": 34 | newValue = newValue.sub(changeValue); 35 | break; 36 | case "multiply": 37 | newValue = newValue.mul(changeValue); 38 | break; 39 | case "divide": 40 | newValue = newValue.div(changeValue); 41 | break; 42 | default: 43 | throw new Error("operation not implemented"); 44 | } 45 | total = roundBig(newValue, 2); 46 | 47 | if (total >= 0) { 48 | return total; 49 | } 50 | return 0; 51 | } 52 | 53 | export function intlFormat(amount: number, userOptions: UserOptions): string { 54 | return new Intl.NumberFormat(userOptions.locale, { 55 | style: "currency", 56 | currency: userOptions.currencyCode, 57 | currencyDisplay: "symbol", 58 | }).format(amount); 59 | } 60 | 61 | export function focusRef(ref: RefObject) { 62 | if (ref.current) { 63 | ref.current.focus(); 64 | } 65 | } 66 | 67 | export function createBudgetNameList(list: Budget[]): SearchOption[] { 68 | return list 69 | .filter((b: Budget) => b.id !== undefined && b.name !== undefined) 70 | .map((b: Budget) => { 71 | return { id: b.id, item: "", name: b.name }; 72 | }); 73 | } 74 | 75 | export function parseLocaleNumber( 76 | stringNumber: string, 77 | locale: string | undefined, 78 | ): number { 79 | const thousandSeparator = Intl.NumberFormat(locale) 80 | .format(11111) 81 | .replace(/\p{Number}/gu, ""); 82 | const decimalSeparator = Intl.NumberFormat(locale) 83 | .format(1.1) 84 | .replace(/\p{Number}/gu, ""); 85 | 86 | return Number.parseFloat( 87 | stringNumber 88 | .replace(new RegExp(`\\${thousandSeparator}`, "g"), "") 89 | .replace(new RegExp(`\\${decimalSeparator}`), "."), 90 | ); 91 | } 92 | 93 | export function median(arr: number[]): number { 94 | if (!arr.length) return 0; 95 | 96 | const s = [...arr].sort((a, b) => Big(a).minus(b).toNumber()); 97 | const mid = Math.floor(s.length / 2); 98 | return s.length % 2 === 0 99 | ? Big(s[mid - 1]) 100 | .plus(s[mid]) 101 | .div(2) 102 | .toNumber() 103 | : s[mid]; 104 | } 105 | 106 | export function getNestedProperty( 107 | object: O, 108 | firstProp: K, 109 | secondProp: L, 110 | ): O[K][L] { 111 | return object[firstProp][secondProp]; 112 | } 113 | 114 | export function getNestedValues( 115 | list: T[] | undefined, 116 | prop1: K, 117 | prop2: L, 118 | ): T[K][L][] { 119 | // biome-ignore lint/style/noNonNullAssertion: 120 | return list!.map((o: T) => { 121 | return getNestedProperty(o, prop1, prop2); 122 | }); 123 | } 124 | 125 | export function getLabelKey(option: unknown): string { 126 | const label = option as SearchOption; 127 | return label.item ? `${label.name} ${label.item}` : `${label.name}`; 128 | } 129 | 130 | export function getLabelKeyFilteredItem(option: unknown): string { 131 | const label = option as FilteredItem; 132 | return `${label.item} (${label.name} ${label.type.toLowerCase()})`; 133 | } 134 | 135 | export function saveLastOpenedBudget( 136 | name: string, 137 | navigateFn: NavigateFunction, 138 | ) { 139 | navigateFn(`/${name}`); 140 | localStorage.setItem("guitos_lastOpenedBudget", name); 141 | } 142 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "erasableSyntaxOnly": true, 7 | "esModuleInterop": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "isolatedModules": true, 10 | "jsx": "react-jsx", 11 | "lib": ["dom", "dom.iterable", "ES2023"], 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "noEmit": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noImplicitAny": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "resolveJsonModule": true, 20 | "skipLibCheck": true, 21 | "strictNullChecks": true, 22 | "strict": true, 23 | "target": "ES2023", 24 | "types": ["vite-plugin-pwa/client"] 25 | }, 26 | "include": ["src", "e2e", "vite.config.ts"] 27 | } 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react-swc"; 2 | import { defineConfig } from "vite"; 3 | import { VitePWA } from "vite-plugin-pwa"; 4 | import { sri } from "vite-plugin-sri3"; 5 | 6 | // biome-ignore lint/style/noDefaultExport: 7 | export default defineConfig((_) => { 8 | return { 9 | build: { 10 | outDir: "build", 11 | rollupOptions: { 12 | external: ["@emotion/is-prop-valid"], // temp fix for rolldown-vite https://github.com/rolldown/rolldown/issues/4051#issuecomment-2786061196 13 | }, 14 | }, 15 | define: { 16 | APP_VERSION: JSON.stringify(process.env.npm_package_version), 17 | }, 18 | resolve: { 19 | extensions: [".js", ".ts", ".tsx"], 20 | }, 21 | plugins: [ 22 | react({ devTarget: "es2022" }), 23 | VitePWA({ 24 | manifest: { 25 | theme_color: "#343746", 26 | background_color: "#343746", 27 | display: "standalone", 28 | scope: "/", 29 | start_url: "/", 30 | short_name: "guitos", 31 | name: "guitos", 32 | icons: [ 33 | { 34 | src: "favicon.svg", 35 | sizes: "any", 36 | type: "image/svg+xml", 37 | }, 38 | ], 39 | }, 40 | }), 41 | sri(), 42 | ], 43 | test: { 44 | clearMocks: true, 45 | coverage: { 46 | provider: "v8", 47 | include: ["src/**"], 48 | exclude: ["**/*.mother.ts", "**/*.test.ts", "**/*.test.tsx"], 49 | }, 50 | pool: "vmThreads", 51 | globals: true, 52 | mockClear: true, 53 | environment: "happy-dom", 54 | setupFiles: ["./src/setupTests.ts", "console-fail-test/setup"], 55 | include: [ 56 | "src/**/*.{test,spec}.?(c|m)[jt]s?(x)", 57 | "src/*.{test,spec}.?(c|m)[jt]s?(x)", 58 | ], 59 | }, 60 | }; 61 | }); 62 | --------------------------------------------------------------------------------