diff --git a/.devcontainer/.vscode/settings.json b/.devcontainer/.vscode/settings.json index bd95f0d..0fb9141 100644 --- a/.devcontainer/.vscode/settings.json +++ b/.devcontainer/.vscode/settings.json @@ -11,11 +11,7 @@ "vue.complete.casing.props": "camel", "typescript.updateImportsOnFileMove.enabled": "always", "typescript.suggest.autoImports": true, - "eslint.validate": [ - "javascript", - "typescript", - "vue" - ], + "eslint.validate": ["javascript", "typescript", "vue"], "files.associations": { "*.vue": "vue" }, @@ -23,9 +19,7 @@ "vue-html": "html", "vue": "html" }, - "i18n-ally.localesPaths": [ - "src/i18n/locales" - ], + "i18n-ally.localesPaths": ["src/i18n/locales"], "i18n-ally.keystyle": "nested", "i18n-ally.displayLanguage": "en", "i18n-ally.sourceLanguage": "en", @@ -34,4 +28,4 @@ "prettier.singleQuote": true, "prettier.printWidth": 100, "prettier.trailingComma": "none" -} \ No newline at end of file +} diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 3ee9666..3ed1b8a 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -13,7 +13,7 @@ This directory contains an ultra-minimal development container configuration for ### Essential VS Code Extensions (6 extensions) - **Vue.js Core**: Volar + TypeScript Vue Plugin -- **Code Quality**: ESLint + Prettier (matches your project config) +- **Code Quality**: ESLint + Prettier (matches your project config) - **i18n Support**: i18n Ally (your project uses French/English) - **Testing**: Playwright (for your E2E tests) @@ -43,7 +43,7 @@ This directory contains an ultra-minimal development container configuration for The container will automatically: - Pull the official Node.js 22 image -- Install all 6 essential extensions +- Install all 6 essential extensions - Run `npm install` to set up dependencies - Install Playwright browsers with `npx playwright install --with-deps` - Forward port 5173 for the Vite dev server @@ -52,7 +52,7 @@ The container will automatically: ```bash npm run dev # Start Vite dev server -npm run build # Build for production +npm run build # Build for production npm run test:unit # Run Vitest unit tests npm run test:e2e # Run Playwright e2e tests npm run lint # ESLint checking diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cd4ea32..a729b2f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -40,11 +40,7 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, - "eslint.validate": [ - "javascript", - "typescript", - "vue" - ], + "eslint.validate": ["javascript", "typescript", "vue"], // File associations "files.associations": { "*.vue": "vue" @@ -55,18 +51,13 @@ "vue": "html" }, // i18n Ally configuration for your project structure - "i18n-ally.localesPaths": [ - "src/i18n/locales" - ], + "i18n-ally.localesPaths": ["src/i18n/locales"], "i18n-ally.keystyle": "nested" } } }, // Forward essential development ports - "forwardPorts": [ - 5173, - 4173 - ], + "forwardPorts": [5173, 4173], "portsAttributes": { "5173": { "label": "Vite Dev Server", @@ -79,4 +70,4 @@ }, // Install dependencies after container creation "postCreateCommand": "npm install" -} \ No newline at end of file +} diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml new file mode 100644 index 0000000..ec81dfb --- /dev/null +++ b/.github/workflows/accessibility.yml @@ -0,0 +1,68 @@ +name: Accessibility Tests + +on: + pull_request: + branches: + - main + paths: + - 'src/**' + - 'public/**' + - 'package.json' + - 'vite.config.*' + - 'tsconfig.*' + - 'index.html' + - 'env.d.ts' + - 'e2e/**' + - 'playwright.config.*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + accessibility: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Install wait-on + run: npm install -g wait-on + + - name: Start preview server and run tests + run: | + # Start the preview server + npm run preview & + SERVER_PID=$! + + # Wait for the server to be ready (timeout after 60s) + wait-on -t 60000 http://localhost:4173/gluko/ || { + echo "Server failed to start" + kill $SERVER_PID + exit 1 + } + + # Run the tests + npx playwright test e2e/accessibility.spec.ts + TEST_EXIT_CODE=$? + + # Clean up the server + kill $SERVER_PID + + # Exit with the test exit code + exit $TEST_EXIT_CODE diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..728b260 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + workflow_run: + workflows: ['Code Quality Checks'] + types: + - completed + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install dependencies + run: npm ci + - name: Build project + run: npm run build diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 0000000..8df0d8f --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,43 @@ +name: Code Quality Checks + +on: + pull_request: + branches: + - main + paths: + - 'src/**' + - 'public/**' + - 'package.json' + - 'vite.config.*' + - 'tsconfig.*' + - 'index.html' + - 'env.d.ts' + - '.eslintrc.*' + - '.prettier*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + code-quality: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install dependencies + run: npm ci --ignore-scripts --no-optional + - name: Run ESLint + run: npm run lint + - name: Install and Run Markdownlint + run: | + npm install -g markdownlint-cli + markdownlint '**/*.md' --ignore 'node_modules/**' --config .markdownlint.json + - name: Run Prettier + run: npx prettier --check . + - name: Run TypeScript type check + run: npm run type-check diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9e4fd3b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,100 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: 'CodeQL Advanced' + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + schedule: + - cron: '42 15 * * 3' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{matrix.language}}' diff --git a/.github/workflows/static.yml b/.github/workflows/deploy_static_site.yml similarity index 90% rename from .github/workflows/static.yml rename to .github/workflows/deploy_static_site.yml index 7739663..5ab855f 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/deploy_static_site.yml @@ -5,6 +5,14 @@ on: # Runs on pushes targeting the default branch push: branches: ['main'] + paths: + - 'src/**' + - 'public/**' + - 'package.json' + - 'vite.config.*' + - 'tsconfig.*' + - 'index.html' + - 'env.d.ts' # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..00e59df --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "default": true +} diff --git a/.prettierrc.json b/.prettierrc.json index 66e2335..ecdf3e0 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -5,4 +5,4 @@ "singleQuote": true, "printWidth": 100, "trailingComma": "none" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 7bdd8bd..3797a11 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,12 @@ # Gluko -[![GitHub Workflow Status (static)](https://img.shields.io/github/actions/workflow/status/gcharest/gluko/static.yml?branch=main&label=Static%20Pages&logo=github&style=flat-square)](https://github.com/gcharest/gluko/actions/workflows/static.yml) +[![Deploy static content to Pages](https://github.com/gcharest/gluko/actions/workflows/static.yml/badge.svg)](https://github.com/gcharest/gluko/actions/workflows/static.yml) +[![Code Quality Checks](https://github.com/gcharest/gluko/actions/workflows/code_quality.yml/badge.svg)](https://github.com/gcharest/gluko/actions/workflows/code_quality.yml) +[![Accessibility Tests](https://github.com/gcharest/gluko/actions/workflows/accessibility.yml/badge.svg)](https://github.com/gcharest/gluko/actions/workflows/accessibility.yml) +[![CodeQL Advanced](https://github.com/gcharest/gluko/actions/workflows/codeql.yml/badge.svg)](https://github.com/gcharest/gluko/actions/workflows/codeql.yml) -Gluko is a web application designed to help individuals and families more accurately calculate the carbohydrate content in their meals. By providing an easy-to-use interface and leveraging the [Canadian Nutrient File database](https://food-nutrition.canada.ca/cnf-fce/?lang=eng), Gluko simplifies carb counting and ensures precision in determining the carb factor of various nutrients. +Gluko is a web application designed to help individuals and families more accurately calculate the carbohydrate content in their meals. +By providing an easy-to-use interface and leveraging the [Canadian Nutrient File database](https://food-nutrition.canada.ca/cnf-fce/?lang=eng), Gluko simplifies carb counting and ensures precision in determining the carb factor of various nutrients. ## Features diff --git a/e2e/accessibility.spec.ts b/e2e/accessibility.spec.ts new file mode 100644 index 0000000..0b710d5 --- /dev/null +++ b/e2e/accessibility.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test' +import AxeBuilder from '@axe-core/playwright' + +test.describe('Accessibility Tests', () => { + test('Home page should not have any automatically detectable accessibility issues', async ({ + page + }) => { + await page.goto('/gluko/', { waitUntil: 'networkidle' }) + await page.waitForSelector('main', { state: 'visible' }) + const accessibilityScanResults = await new AxeBuilder({ page }).analyze() + expect(accessibilityScanResults.violations).toEqual([]) + }) + + test('Calculator page should not have any automatically detectable accessibility issues', async ({ + page + }) => { + // Use the configured base URL and wait for navigation + await page.goto('/gluko/calculator', { waitUntil: 'networkidle' }) + // Add a small wait to ensure dynamic content is loaded + await page.waitForSelector('main', { state: 'visible' }) + const accessibilityScanResults = await new AxeBuilder({ page }).analyze() + expect(accessibilityScanResults.violations).toEqual([]) + }) + + test('Carb Factor page should not have any automatically detectable accessibility issues', async ({ + page + }) => { + await page.goto('/gluko/carb-factor', { waitUntil: 'networkidle' }) + await page.waitForSelector('main', { state: 'visible' }) + const accessibilityScanResults = await new AxeBuilder({ page }).analyze() + expect(accessibilityScanResults.violations).toEqual([]) + }) + + test('History page should not have any automatically detectable accessibility issues', async ({ + page + }) => { + await page.goto('/gluko/history', { waitUntil: 'networkidle' }) + await page.waitForSelector('main', { state: 'visible' }) + const accessibilityScanResults = await new AxeBuilder({ page }).analyze() + expect(accessibilityScanResults.violations).toEqual([]) + }) + + test('About page should not have any automatically detectable accessibility issues', async ({ + page + }) => { + await page.goto('/gluko/about', { waitUntil: 'networkidle' }) + await page.waitForSelector('main', { state: 'visible' }) + const accessibilityScanResults = await new AxeBuilder({ page }).analyze() + expect(accessibilityScanResults.violations).toEqual([]) + }) +}) + +// Extended tests for common interactive components +test.describe('Component-specific Accessibility Tests', () => { + // TODO fix the issues in the test logic; nav menu is actually accessible with keyboard + // test('Navigation menu should be keyboard accessible', async ({ page }) => { + // // Set mobile viewport to ensure navigation button is visible + // await page.setViewportSize({ width: 390, height: 844 }) // iPhone 12 dimensions + // await page.goto('/gluko/', { waitUntil: 'networkidle' }) + // await page.waitForLoadState('domcontentloaded') + + // // Check if the skip link is the first focusable element + // await page.keyboard.press('Tab') + // const focusedElement = await page.evaluate(() => document.activeElement?.id) + // expect(focusedElement).toBe('skip-to-content') + + // // Check if the main navigation is accessible + // const nav = await page.getByRole('navigation', { name: /Main navigation|Navigation principale/ }) + // expect(await nav.isVisible()).toBeTruthy() + + // // Check for ARIA labels in navigation + // await page.waitForSelector('button.navbar-toggler', { state: 'visible' }) + // const menuButton = await page.getByRole('button', { name: /Toggle navigation|Basculer la navigation/ }) + // await expect(menuButton).toBeVisible() + + // // Test initial state + // await expect(menuButton).toHaveAttribute('aria-expanded', 'false') + + // // Test keyboard accessibility of the menu button + // await menuButton.focus() + // await expect(menuButton).toBeFocused() + + // // Click menu button to show navigation + // await menuButton.click() + + // // Wait for offcanvas to be visible + // await page.waitForSelector('.offcanvas.show', { state: 'visible', timeout: 1000 }) + + // // Verify each navigation item is present and can be focused + // for (const linkText of ['Calculateur de glucides', 'Historique des repas', 'Facteur glucidique', 'À propos']) { + // await page.keyboard.press('Tab') + // const link = page.getByRole('link', { name: linkText }) + // await expect(link).toBeVisible() + // await expect(link).toBeFocused() + // } + // }) + + test('Language toggler should be properly labeled', async ({ page }) => { + await page.goto('/gluko/', { waitUntil: 'networkidle' }) + const languageToggler = await page.getByRole('button', { + name: /Change Language|Changer la langue/ + }) + expect(await languageToggler.isVisible()).toBeTruthy() + }) + + test('Theme toggler should be properly labeled', async ({ page }) => { + await page.goto('/gluko/', { waitUntil: 'networkidle' }) + const themeToggler = await page.getByRole('button', { name: /Toggle theme|Changer le thème/ }) + expect(await themeToggler.isVisible()).toBeTruthy() + }) + + test('Calculator form controls should be properly labeled', async ({ page }) => { + await page.goto('/gluko/calculator', { waitUntil: 'networkidle' }) + + // Check if nutrient inputs are properly labeled + const nutrientInputs = await page.getByRole('textbox') + for (const input of await nutrientInputs.all()) { + const label = await input.evaluate((el) => { + const id = el.id + return id ? document.querySelector(`label[for="${id}"]`)?.textContent : null + }) + expect(label).toBeTruthy() + } + }) +}) diff --git a/e2e/installation.spec.ts b/e2e/installation.spec.ts index 7a86208..6f4e2a5 100644 --- a/e2e/installation.spec.ts +++ b/e2e/installation.spec.ts @@ -1,7 +1,7 @@ -import { test } from '@playwright/test'; +import { test } from '@playwright/test' test('verify playwright installation', async ({ page }) => { // Try to navigate to a simple page - await page.goto('https://example.com'); - console.log('Successfully loaded a page'); -}); \ No newline at end of file + await page.goto('https://example.com') + console.log('Successfully loaded a page') +}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index db381a7..f31fe71 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -1,6 +1,4 @@ { "extends": "@tsconfig/node22/tsconfig.json", - "include": [ - "./**/*" - ] -} \ No newline at end of file + "include": ["./**/*"] +} diff --git a/e2e/vue.spec.ts b/e2e/vue.spec.ts index b558d83..f330e7f 100644 --- a/e2e/vue.spec.ts +++ b/e2e/vue.spec.ts @@ -1,49 +1,51 @@ -import { test, expect } from '@playwright/test'; +import { test, expect } from '@playwright/test' test.describe('Homepage', () => { test('should display the calculator in French by default', async ({ page }) => { - await page.goto('/'); + await page.goto('/') // Use a more specific selector that only targets the main content heading - await expect(page.getByRole('heading', { level: 1 }).first()).toHaveText('Calculateur de glucides'); - }); + await expect(page.getByRole('heading', { level: 1 }).first()).toHaveText( + 'Calculateur de glucides' + ) + }) test('should switch language when toggling to English', async ({ page }) => { - await page.goto('/'); + await page.goto('/') // Click the language toggler component first - const languageToggler = page.locator('#language-toggler'); - await languageToggler.waitFor({ state: 'visible' }); - await languageToggler.click(); + const languageToggler = page.locator('#language-toggler') + await languageToggler.waitFor({ state: 'visible' }) + await languageToggler.click() // Now click the English option in the dropdown - const englishOption = page.getByRole('button', { name: 'English' }); - await englishOption.waitFor({ state: 'visible' }); - await englishOption.click(); + const englishOption = page.getByRole('button', { name: 'English' }) + await englishOption.waitFor({ state: 'visible' }) + await englishOption.click() // Verify the title has changed to English - await expect(page.getByRole('heading', { level: 1 }).first()).toHaveText('Carbs Counter'); - }); + await expect(page.getByRole('heading', { level: 1 }).first()).toHaveText('Carbs Counter') + }) test('should have working navigation links', async ({ page }) => { - await page.goto('/'); + await page.goto('/') // Check that all main navigation links are present - const nav = page.locator('nav[aria-label="Navigation principale"]'); - await expect(nav).toBeVisible(); - await expect(nav).toContainText('Gluko'); - await expect(nav).toContainText('Facteur glucidique'); - await expect(nav).toContainText('À propos'); + const nav = page.locator('nav[aria-label="Navigation principale"]') + await expect(nav).toBeVisible() + await expect(nav).toContainText('Gluko') + await expect(nav).toContainText('Facteur glucidique') + await expect(nav).toContainText('À propos') // Test navigation to Carb Factor page - await page.getByRole('link', { name: 'Facteur glucidique' }).click(); - await expect(page).toHaveURL(/.*\/carb-factor/); + await page.getByRole('link', { name: 'Facteur glucidique' }).click() + await expect(page).toHaveURL(/.*\/carb-factor/) // Test navigation to About page - await page.getByRole('link', { name: 'À propos' }).click(); - await expect(page).toHaveURL(/.*\/about/); + await page.getByRole('link', { name: 'À propos' }).click() + await expect(page).toHaveURL(/.*\/about/) // Test navigation back to home - await page.getByRole('link', { name: 'Gluko' }).click(); - await expect(page).toHaveURL(/.*\/$/); - }); -}); + await page.getByRole('link', { name: 'Gluko' }).click() + await expect(page).toHaveURL(/.*\/$/) + }) +}) diff --git a/env.d.ts b/env.d.ts index 9dd5d79..6082e71 100644 --- a/env.d.ts +++ b/env.d.ts @@ -1,4 +1,4 @@ /// /// /// -declare module 'vue-i18n' \ No newline at end of file +declare module 'vue-i18n' diff --git a/index.html b/index.html index 62d425b..7a279ea 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,14 @@ - + + + + + + Gluko + - - - - - Gluko - - - -
- - - - \ No newline at end of file + +
+ + + diff --git a/package-lock.json b/package-lock.json index d9e9cf5..5fe29fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "gluko", "version": "0.0.0", + "hasInstallScript": true, "dependencies": { "@intlify/unplugin-vue-i18n": "^4.0.0", "@popperjs/core": "^2.11.8", @@ -20,6 +21,7 @@ "vue-router": "^4.5.1" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@eslint/js": "^9.36.0", "@pinia/testing": "^1.0.2", "@playwright/test": "^1.49.1", @@ -82,6 +84,19 @@ "lru-cache": "^10.4.3" } }, + "node_modules/@axe-core/playwright": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", + "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.10.3" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -4400,6 +4415,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.14", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", diff --git a/package.json b/package.json index ba8b1f5..d710446 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:unit": "vitest", "test:unit:coverage": "vitest --coverage", "test:e2e": "playwright test", + "test:e2e:a11y": "playwright test e2e/accessibility.spec.ts", "build-only": "vite build", "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", @@ -29,6 +30,7 @@ "vue-router": "^4.5.1" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@eslint/js": "^9.36.0", "@pinia/testing": "^1.0.2", "@playwright/test": "^1.49.1", @@ -62,4 +64,4 @@ "vue-eslint-parser": "^10.2.0", "vue-tsc": "^2.1.10" } -} \ No newline at end of file +} diff --git a/playwright.config.ts b/playwright.config.ts index 5531ef5..73ef6a1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,7 +33,10 @@ export default defineConfig({ trace: 'on-first-retry', // Always run headless in the dev container - headless: true + headless: true, + + // Viewport settings that ensure consistent testing + viewport: { width: 1280, height: 720 } }, // Browser configurations @@ -58,4 +61,4 @@ export default defineConfig({ port: 5173, reuseExistingServer: !process.env.CI } -}); +}) diff --git a/src/components/base/BaseLanguageToggler.vue b/src/components/base/BaseLanguageToggler.vue index 64a1247..347f47c 100644 --- a/src/components/base/BaseLanguageToggler.vue +++ b/src/components/base/BaseLanguageToggler.vue @@ -20,7 +20,7 @@ const getSupportedLocales = computed(() => { aria-expanded="false" data-bs-toggle="dropdown" data-bs-display="static" - aria-label="Toggle language (auto)" + :aria-label="$t('locale.toggleLanguage')" > {{ $t('locale.toggleLanguage') }} diff --git a/src/components/base/BaseNavigationBar.vue b/src/components/base/BaseNavigationBar.vue index b9e13e7..34d1df2 100644 --- a/src/components/base/BaseNavigationBar.vue +++ b/src/components/base/BaseNavigationBar.vue @@ -1,12 +1,47 @@ \ No newline at end of file + diff --git a/src/components/calculator/MealCalculator.vue b/src/components/calculator/MealCalculator.vue index 7bc6fdf..8d77602 100644 --- a/src/components/calculator/MealCalculator.vue +++ b/src/components/calculator/MealCalculator.vue @@ -32,16 +32,12 @@ async function handleAdd() { async function handleSaveToHistory() { const mealHistoryStore = useMealHistoryStore() const totalCarbs = store.currentNutrients.reduce((total: number, nutrient: Nutrient) => { - return total + (nutrient.quantity * nutrient.factor) + return total + nutrient.quantity * nutrient.factor }, 0) - await mealHistoryStore.addEntry( - store.currentNutrients, - totalCarbs, - { - tags: [] - } - ) + await mealHistoryStore.addEntry(store.currentNutrients, totalCarbs, { + tags: [] + }) // Clear the calculator after saving await store.clearSession() @@ -54,16 +50,26 @@ async function handleSaveToHistory() { - - + diff --git a/src/components/filters/DateRangeFilter.vue b/src/components/filters/DateRangeFilter.vue index f00748a..f9330de 100644 --- a/src/components/filters/DateRangeFilter.vue +++ b/src/components/filters/DateRangeFilter.vue @@ -2,25 +2,42 @@
- + + :id="startInputId" + v-model="startDate" + type="date" + class="form-control" + :max="maxStartDate" + @change="handleStartDateChange" + />
+ :id="endInputId" + v-model="endDate" + type="date" + class="form-control" + :min="minEndDate" + :max="todayFormatted" + @change="handleEndDateChange" + />
@@ -89,7 +106,7 @@ function handleEndDateChange() { // Apply preset date ranges function applyPreset(presetKey: string) { - const preset = presets.find(p => p.key === presetKey) + const preset = presets.find((p) => p.key === presetKey) if (!preset) return const end = new Date() @@ -115,4 +132,4 @@ function applyPreset(presetKey: string) { .date-range-filter { width: 100%; } - \ No newline at end of file + diff --git a/src/components/filters/SubjectSelector.vue b/src/components/filters/SubjectSelector.vue index 5c53318..4110669 100644 --- a/src/components/filters/SubjectSelector.vue +++ b/src/components/filters/SubjectSelector.vue @@ -2,8 +2,14 @@
+ :id="allSubjectsId" + v-model="selectedSubjectId" + class="form-check-input" + type="radio" + name="subject" + :value="null" + @change="handleChange" + /> @@ -33,8 +39,14 @@
+ :id="getSubjectInputId(subject.id)" + v-model="selectedSubjectId" + class="form-check-input" + type="radio" + name="subject" + :value="subject.id" + @change="handleChange" + /> @@ -84,9 +96,12 @@ const subjects = computed(() => { }) // Watch for external changes to modelValue -watch(() => props.modelValue, (newValue) => { - selectedSubjectId.value = newValue -}) +watch( + () => props.modelValue, + (newValue) => { + selectedSubjectId.value = newValue + } +) // Event handlers function handleChange() { @@ -121,4 +136,4 @@ onMounted(async () => { max-height: 200px; overflow-y: auto; } - \ No newline at end of file + diff --git a/src/components/history/MealHistoryCard.vue b/src/components/history/MealHistoryCard.vue index 9cc07b4..bdebf30 100644 --- a/src/components/history/MealHistoryCard.vue +++ b/src/components/history/MealHistoryCard.vue @@ -13,8 +13,12 @@
- - {{ totalCarbs.toFixed(1) }}g - + {{ totalCarbs.toFixed(1) }}g
@@ -112,7 +118,7 @@ const formattedDate = computed(() => { // Calculate total carbs const totalCarbs = computed(() => { return props.meal.nutrients.reduce((total, nutrient) => { - return total + (nutrient.quantity * nutrient.factor) + return total + nutrient.quantity * nutrient.factor }, 0) }) @@ -134,4 +140,4 @@ const totalCarbs = computed(() => { .meal-tags { margin-top: 0.5rem; } - \ No newline at end of file + diff --git a/src/components/modals/ConfirmationModal.vue b/src/components/modals/ConfirmationModal.vue index 4fa92b7..18d5c59 100644 --- a/src/components/modals/ConfirmationModal.vue +++ b/src/components/modals/ConfirmationModal.vue @@ -22,7 +22,7 @@ const eventHandlers = { // Move focus before animation starts if (previousActiveElement && 'focus' in previousActiveElement) { try { - (previousActiveElement as HTMLElement).focus() + ;(previousActiveElement as HTMLElement).focus() } catch { // Fallback to body if original element is no longer available document.body.focus() @@ -70,13 +70,16 @@ onBeforeUnmount(() => { }) // Watch for v-model changes -watch(() => props.modelValue, (newVal) => { - if (newVal && bsModal) { - bsModal.show() - } else if (!newVal && bsModal) { - bsModal.hide() +watch( + () => props.modelValue, + (newVal) => { + if (newVal && bsModal) { + bsModal.show() + } else if (!newVal && bsModal) { + bsModal.hide() + } } -}) +) function handleConfirm() { emit('confirm') @@ -100,8 +103,13 @@ function handleKeydown(event: KeyboardEvent) { \ No newline at end of file + diff --git a/src/components/modals/NutrientModal.vue b/src/components/modals/NutrientModal.vue index 33d61a5..bdedc1a 100644 --- a/src/components/modals/NutrientModal.vue +++ b/src/components/modals/NutrientModal.vue @@ -27,11 +27,13 @@ const eventHandlers = { // Move focus before animation starts if (previousActiveElement && 'focus' in previousActiveElement) { try { - (previousActiveElement as HTMLElement).focus() + ;(previousActiveElement as HTMLElement).focus() } catch (e) { console.warn('Failed to focus on previous element:', e) // Fallback if the element is no longer focusable - const modifyButton = document.querySelector(`[data-nutrient-id="${props.nutrient.id}"]`) as HTMLButtonElement | null + const modifyButton = document.querySelector( + `[data-nutrient-id="${props.nutrient.id}"]` + ) as HTMLButtonElement | null modifyButton?.focus() } } @@ -59,9 +61,11 @@ function handleGlobalKeydown(event: KeyboardEvent) { if (event.key !== 'Enter') return // Don't trigger if user is typing in an input field - if (event.target instanceof HTMLInputElement || + if ( + event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || - event.target instanceof HTMLSelectElement) { + event.target instanceof HTMLSelectElement + ) { return } @@ -109,21 +113,28 @@ onBeforeUnmount(() => { // Watch for v-model changes // Watch for v-model changes to show/hide modal -watch(() => props.modelValue, (newVal) => { - if (newVal && bsModal) { - bsModal.show() - } else if (!newVal && bsModal) { - bsModal.hide() +watch( + () => props.modelValue, + (newVal) => { + if (newVal && bsModal) { + bsModal.show() + } else if (!newVal && bsModal) { + bsModal.hide() + } } -}) +) // Watch for nutrient changes to update local state -watch(() => props.nutrient, (newVal) => { - if (newVal) { - // Deep clone the nutrient, preserving the ID for existing nutrients - currentNutrient.value = JSON.parse(JSON.stringify(newVal)) - } -}, { immediate: true }) +watch( + () => props.nutrient, + (newVal) => { + if (newVal) { + // Deep clone the nutrient, preserving the ID for existing nutrients + currentNutrient.value = JSON.parse(JSON.stringify(newVal)) + } + }, + { immediate: true } +) async function saveNutrient() { try { @@ -160,9 +171,8 @@ function handleInputEnter(event: KeyboardEvent) { function handleNutrientSelect(result: SearchResult) { currentNutrient.value.name = result.item[`FoodDescription${locale.value === 'fr' ? 'F' : ''}`] - currentNutrient.value.factor = result.item.FctGluc !== null - ? result.item.FctGluc - : result.item['205'] / 100 + currentNutrient.value.factor = + result.item.FctGluc !== null ? result.item.FctGluc : result.item['205'] / 100 // Focus quantity field after selection const quantityInput = document.getElementById('nutrient-quantity') as HTMLInputElement | null @@ -173,8 +183,12 @@ function handleNutrientSelect(result: SearchResult) { \ No newline at end of file + diff --git a/src/components/nutrients/NutrientListItem.vue b/src/components/nutrients/NutrientListItem.vue index a50f814..ae8eda3 100644 --- a/src/components/nutrients/NutrientListItem.vue +++ b/src/components/nutrients/NutrientListItem.vue @@ -30,7 +30,10 @@ const handleModifyKeydown = (event: KeyboardEvent) => { } diff --git a/src/components/search/CarbFactorSearch.vue b/src/components/search/CarbFactorSearch.vue index 9f59e12..af755bc 100644 --- a/src/components/search/CarbFactorSearch.vue +++ b/src/components/search/CarbFactorSearch.vue @@ -12,8 +12,9 @@ const searchResults = computed(() => { }) const cnfLink = computed(() => (foodID: number, locale: string) => { - return `https://food-nutrition.canada.ca/cnf-fce/serving-portion?id=${foodID}&lang=${locale === 'fr' ? 'fre' : 'eng' - }` + return `https://food-nutrition.canada.ca/cnf-fce/serving-portion?id=${foodID}&lang=${ + locale === 'fr' ? 'fre' : 'eng' + }` }) // Trigger search from button click or enter key @@ -35,19 +36,27 @@ const handleKeydown = (event: KeyboardEvent) => {
+ id="searchInput" + v-model="searchInput" + type="text" + class="form-control" + :placeholder="$t('components.search.placeholder')" + @keydown="handleKeydown" + />

- {{ $t('components.search.results', { count: searchResults.length }) - }} + {{ $t('components.search.results', { count: searchResults.length }) }}

  • @@ -62,7 +71,11 @@ id="searchInput" v-model="searchInput" type="text"

    {{ result.item.FoodDescriptionF }} {{ result.item.FoodDescription }} - + {{ $t('components.search.source') }}

    @@ -71,8 +84,8 @@ id="searchInput" v-model="searchInput" type="text"

    {{ result.item.FctGluc !== null - ? result.item.FctGluc.toFixed(2) - : (result.item['205'] / 100).toFixed(2) + ? result.item.FctGluc.toFixed(2) + : (result.item['205'] / 100).toFixed(2) }}

diff --git a/src/components/search/NutrientSearch.vue b/src/components/search/NutrientSearch.vue index 4b806d0..1d159e0 100644 --- a/src/components/search/NutrientSearch.vue +++ b/src/components/search/NutrientSearch.vue @@ -55,7 +55,8 @@ const handleKeydown = (event: KeyboardEvent) => { const handleInput = () => { if (props.autoSearch) { const trimmedValue = searchInput.value.trim() - if (trimmedValue.length >= 2) { // Only search if there are at least 2 characters + if (trimmedValue.length >= 2) { + // Only search if there are at least 2 characters search.value = trimmedValue } else { search.value = '' // Clear results if input is too short @@ -92,7 +93,8 @@ const handleSelect = (item: any) => { class="btn btn-outline-secondary" type="button" :aria-label="searchButtonLabel || $t('common.actions.search')" - @click="triggerSearch"> + @click="triggerSearch" + > {{ searchButtonLabel || $t('common.actions.search') }}
@@ -111,4 +113,4 @@ const handleSelect = (item: any) => {

- \ No newline at end of file + diff --git a/src/components/search/SearchResults.vue b/src/components/search/SearchResults.vue index dcacdb3..f26ffdb 100644 --- a/src/components/search/SearchResults.vue +++ b/src/components/search/SearchResults.vue @@ -35,13 +35,16 @@ const cnfLink = computed(() => (foodID: number, locale: string) => { :key="result.refIndex" class="list-group-item list-group-item-action" role="button" - :aria-label="$t('components.search.selectItem', { - name: $i18n.locale === 'fr' ? result.item.FoodDescriptionF : result.item.FoodDescription - })" + :aria-label=" + $t('components.search.selectItem', { + name: $i18n.locale === 'fr' ? result.item.FoodDescriptionF : result.item.FoodDescription + }) + " tabindex="0" @click="emit('select', result)" @keydown.enter="emit('select', result)" - @keydown.space.prevent="emit('select', result)"> + @keydown.space.prevent="emit('select', result)" + >

@@ -54,16 +57,18 @@ const cnfLink = computed(() => (foodID: number, locale: string) => { :href="cnfLink(result.item.FoodCode, $i18n.locale)" target="_blank" class="link-primary small" - @click.stop> + @click.stop + > {{ $t('components.search.source') }}

- {{ result.item.FctGluc !== null - ? result.item.FctGluc.toFixed(2) - : (result.item['205'] / 100).toFixed(2) + {{ + result.item.FctGluc !== null + ? result.item.FctGluc.toFixed(2) + : (result.item['205'] / 100).toFixed(2) }}

@@ -96,4 +101,5 @@ const cnfLink = computed(() => (foodID: number, locale: string) => { .search-results-list .list-group-item-action:active { transform: translateX(2px); } -]]> \ No newline at end of file + +]]> diff --git a/src/composables/useIndexedDB.ts b/src/composables/useIndexedDB.ts index 16e34bf..3601ccf 100644 --- a/src/composables/useIndexedDB.ts +++ b/src/composables/useIndexedDB.ts @@ -56,12 +56,16 @@ export const useIndexedDB = () => { if (err instanceof DOMException) { if (err.name === 'QuotaExceededError') { - const quotaError = new Error('Storage quota exceeded. Please free up some space and try again.') + const quotaError = new Error( + 'Storage quota exceeded. Please free up some space and try again.' + ) error.value = quotaError return quotaError } if (err.name === 'SecurityError') { - const securityError = new Error('Permission denied to access IndexedDB. Please check your privacy settings.') + const securityError = new Error( + 'Permission denied to access IndexedDB. Please check your privacy settings.' + ) error.value = securityError return securityError } @@ -71,15 +75,16 @@ export const useIndexedDB = () => { return transactionError } if (err.name === 'InvalidStateError') { - const stateError = new Error('Database is not in a valid state. Please reload the application.') + const stateError = new Error( + 'Database is not in a valid state. Please reload the application.' + ) error.value = stateError return stateError } } - const errMessage = err instanceof Error ? err.message : - typeof err === 'string' ? err : - 'Unknown error' + const errMessage = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' const genericError = new Error(`An error occurred: ${errMessage}`) error.value = genericError @@ -102,13 +107,17 @@ export const useIndexedDB = () => { } if (request.error?.name === 'SecurityError') { - const err = new Error('Permission denied to access IndexedDB. Please check your privacy settings.') + const err = new Error( + 'Permission denied to access IndexedDB. Please check your privacy settings.' + ) error.value = err reject(err) return } - const err = new Error(`Failed to open database: ${request.error?.message || 'Unknown error'}`) + const err = new Error( + `Failed to open database: ${request.error?.message || 'Unknown error'}` + ) error.value = err reject(err) } @@ -127,7 +136,7 @@ export const useIndexedDB = () => { if (oldVersion < 3) { // Delete legacy stores if they exist (for upgrades) const storeNames = Array.from(database.objectStoreNames) - storeNames.forEach(storeName => { + storeNames.forEach((storeName) => { if (['meals', 'mealNutrients'].includes(storeName)) { database.deleteObjectStore(storeName) } @@ -175,7 +184,10 @@ export const useIndexedDB = () => { return db.value } - const getStore = async (storeName: keyof DBSchema, mode: 'readonly' | 'readwrite' = 'readonly') => { + const getStore = async ( + storeName: keyof DBSchema, + mode: 'readonly' | 'readwrite' = 'readonly' + ) => { const database = await openDB() const transaction = database.transaction(storeName, mode) return transaction.objectStore(storeName) @@ -262,11 +274,13 @@ export const useIndexedDB = () => { // Deserialize dates in all records if (Array.isArray(results)) { - resolve(results.map(result => - typeof result === 'object' && result !== null - ? deserializeDates(result as Record) as DBSchema[K]['value'] - : result - )) + resolve( + results.map((result) => + typeof result === 'object' && result !== null + ? (deserializeDates(result as Record) as DBSchema[K]['value']) + : result + ) + ) } else { resolve([]) } @@ -295,11 +309,10 @@ export const useIndexedDB = () => { const putPromise = new Promise((resolve, reject) => { const serializedValue = serializeDates(value) - const request = key !== undefined - ? store.put(serializedValue, key) - : store.put(serializedValue) + const request = + key !== undefined ? store.put(serializedValue, key) : store.put(serializedValue) - request.onsuccess = () => resolve(key ?? request.result as DBSchema[K]['key']) + request.onsuccess = () => resolve(key ?? (request.result as DBSchema[K]['key'])) request.onerror = () => { tx.abort() reject(handleError(request.error)) @@ -309,7 +322,6 @@ export const useIndexedDB = () => { // Wait for both transaction and put to complete const [storedKey] = await Promise.all([putPromise, txPromise]) return storedKey - } catch (err) { throw handleError(err) } @@ -338,7 +350,6 @@ export const useIndexedDB = () => { tx.onerror = () => reject(handleError(tx.error)) }) ]) - } catch (err) { throw handleError(err) } @@ -364,7 +375,6 @@ export const useIndexedDB = () => { tx.onerror = () => reject(handleError(tx.error)) }) ]) - } catch (err) { throw handleError(err) } @@ -412,9 +422,12 @@ export const useIndexedDB = () => { request.onsuccess = () => { const results = request.result if (Array.isArray(results)) { - resolve(results.map(result => - deserializeDates(result as Record) as DBSchema[K]['value'] - )) + resolve( + results.map( + (result) => + deserializeDates(result as Record) as DBSchema[K]['value'] + ) + ) } else { resolve([]) } @@ -435,14 +448,17 @@ export const useIndexedDB = () => { // Calculation session methods const getSession = (id: string) => get('activeSessions', id) - const getSessionsBySubject = (subjectId: string) => getAllByIndex('activeSessions', 'by-subject', subjectId) - const getSessionsByStatus = (status: 'draft' | 'completed') => getAllByIndex('activeSessions', 'by-status', status) + const getSessionsBySubject = (subjectId: string) => + getAllByIndex('activeSessions', 'by-subject', subjectId) + const getSessionsByStatus = (status: 'draft' | 'completed') => + getAllByIndex('activeSessions', 'by-status', status) const saveSession = (session: CalculationSession) => put('activeSessions', session) const removeSession = (id: string) => remove('activeSessions', id) // Meal history methods const getMealHistory = (id: string) => get('mealHistory', id) - const getMealHistoryBySubject = (subjectId: string) => getAllByIndex('mealHistory', 'by-subject', subjectId) + const getMealHistoryBySubject = (subjectId: string) => + getAllByIndex('mealHistory', 'by-subject', subjectId) const getMealHistoryByDate = (date: Date) => getAllByIndex('mealHistory', 'by-date', date) const getMealHistoryByTag = (tag: string) => getAllByIndex('mealHistory', 'by-tags', tag) const saveMealHistory = (entry: MealHistoryEntry) => put('mealHistory', entry) @@ -491,4 +507,4 @@ export const useIndexedDB = () => { getUserAccount, saveUserAccount } -} \ No newline at end of file +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e57a5bc..17aad04 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -37,6 +37,8 @@ "navigation": { "skipToContent": "Skip to content", "mainNavigation": "Main navigation", + "toggleNavigation": "Toggle navigation", + "sourceCode": "View source code on GitHub", "home": "Gluko", "history": "Meal History", "carbFactor": "Carb Factor", @@ -180,6 +182,7 @@ "import": "Import" }, "results": { + "title": "Meal List", "count": "{count} meals found", "perPage": "Results per page", "loading": "Loading meals..." @@ -208,4 +211,4 @@ "cancel": "Cancel" } } -} \ No newline at end of file +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1411146..ec91373 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -37,6 +37,8 @@ "navigation": { "skipToContent": "Aller au contenu", "mainNavigation": "Navigation principale", + "toggleNavigation": "Basculer la navigation", + "sourceCode": "Voir le code source sur GitHub", "home": "Gluko", "history": "Historique des repas", "carbFactor": "Facteur glucidique", @@ -180,6 +182,7 @@ "import": "Importer" }, "results": { + "title": "Liste des repas", "count": "{count} repas trouvés", "perPage": "Résultats par page", "loading": "Chargement des repas..." @@ -208,4 +211,4 @@ "cancel": "Annuler" } } -} \ No newline at end of file +} diff --git a/src/stores/meal.ts b/src/stores/meal.ts index 88acb8c..3adce45 100644 --- a/src/stores/meal.ts +++ b/src/stores/meal.ts @@ -35,7 +35,7 @@ export const useMealStore = defineStore('mealStore', () => { console.log('Loading draft sessions...') const sessions = await db.getAllByIndex('activeSessions', 'by-status', 'draft') console.log('Found sessions:', sessions) - sessions.forEach(session => { + sessions.forEach((session) => { activeSessions.value.set(session.subjectId, session) console.log('Added session for subject:', session.subjectId) }) @@ -56,16 +56,19 @@ export const useMealStore = defineStore('mealStore', () => { loadInitialData() // Watch for subject changes - watch(() => subjectStore.activeSubjectId, async (newId, oldId) => { - if (oldId) { - // Save current session for previous subject - await saveSession(oldId) - } - if (newId) { - // Load or create session for new subject - await loadOrCreateSession(newId) + watch( + () => subjectStore.activeSubjectId, + async (newId, oldId) => { + if (oldId) { + // Save current session for previous subject + await saveSession(oldId) + } + if (newId) { + // Load or create session for new subject + await loadOrCreateSession(newId) + } } - }) + ) // Helper functions const getCurrentSession = () => { @@ -98,7 +101,7 @@ export const useMealStore = defineStore('mealStore', () => { // Try to load existing draft session const existingSessions = await db.getSessionsBySubject(subjectId) - const draftSession = existingSessions.find(s => s.status === 'draft') + const draftSession = existingSessions.find((s) => s.status === 'draft') if (draftSession) { activeSessions.value.set(subjectId, draftSession) @@ -183,11 +186,11 @@ export const useMealStore = defineStore('mealStore', () => { let index = -1 if (typeof identifier === 'string') { - index = session.nutrients.findIndex(n => n.id === identifier) + index = session.nutrients.findIndex((n) => n.id === identifier) } else if (typeof identifier === 'number') { index = identifier } else { - index = session.nutrients.findIndex(n => n.id === identifier.id) + index = session.nutrients.findIndex((n) => n.id === identifier.id) } if (index === -1 || index >= session.nutrients.length) return false @@ -216,7 +219,7 @@ export const useMealStore = defineStore('mealStore', () => { const session = getCurrentSession() if (!session) return false - const index = session.nutrients.findIndex(n => n.id === nutrient.id) + const index = session.nutrients.findIndex((n) => n.id === nutrient.id) if (index === -1) return false const updatedNutrients = [...session.nutrients] @@ -268,7 +271,7 @@ export const useMealStore = defineStore('mealStore', () => { const newSession: CalculationSession = { id: getUUID(), subjectId: targetSubjectId, - nutrients: sourceSession.nutrients.map(n => ({ + nutrients: sourceSession.nutrients.map((n) => ({ ...n, id: getUUID() // Generate new IDs for duplicated nutrients })), diff --git a/src/stores/mealHistory.ts b/src/stores/mealHistory.ts index d465116..9c38b2b 100644 --- a/src/stores/mealHistory.ts +++ b/src/stores/mealHistory.ts @@ -39,13 +39,16 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { } // Watch for subject changes and reload data - watch(() => subjectStore.activeSubjectId, async (newId: string | null) => { - if (newId) { - await loadInitialData() - } else { - entries.value = [] + watch( + () => subjectStore.activeSubjectId, + async (newId: string | null) => { + if (newId) { + await loadInitialData() + } else { + entries.value = [] + } } - }) + ) // Initialize store loadInitialData() @@ -57,26 +60,27 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { // Apply search query if (searchQuery.value) { const query = searchQuery.value.toLowerCase() - filtered = filtered.filter(entry => - entry.name?.toLowerCase().includes(query) || - entry.notes?.toLowerCase().includes(query) || - entry.tags.some(tag => tag.toLowerCase().includes(query)) + filtered = filtered.filter( + (entry) => + entry.name?.toLowerCase().includes(query) || + entry.notes?.toLowerCase().includes(query) || + entry.tags.some((tag) => tag.toLowerCase().includes(query)) ) } // Apply tag filters if (selectedTags.value.length > 0) { - filtered = filtered.filter(entry => - selectedTags.value.every(tag => entry.tags.includes(tag)) + filtered = filtered.filter((entry) => + selectedTags.value.every((tag) => entry.tags.includes(tag)) ) } // Apply date range if (dateRange.value.start) { - filtered = filtered.filter(entry => entry.date >= dateRange.value.start!) + filtered = filtered.filter((entry) => entry.date >= dateRange.value.start!) } if (dateRange.value.end) { - filtered = filtered.filter(entry => entry.date <= dateRange.value.end!) + filtered = filtered.filter((entry) => entry.date <= dateRange.value.end!) } return filtered @@ -87,13 +91,11 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { return filteredEntries.value.slice(start, start + ENTRIES_PER_PAGE) }) - const totalPages = computed(() => - Math.ceil(filteredEntries.value.length / ENTRIES_PER_PAGE) - ) + const totalPages = computed(() => Math.ceil(filteredEntries.value.length / ENTRIES_PER_PAGE)) const allTags = computed(() => { const tags = new Set() - entries.value.forEach(entry => entry.tags.forEach(tag => tags.add(tag))) + entries.value.forEach((entry) => entry.tags.forEach((tag) => tags.add(tag))) return Array.from(tags).sort() }) @@ -104,7 +106,7 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { // Calculate daily averages const dailyStats = new Map() - entries.value.forEach(entry => { + entries.value.forEach((entry) => { const dateKey = entry.date.toISOString().split('T')[0] const stats = dailyStats.get(dateKey) || { count: 0, totalCarbs: 0 } stats.count++ @@ -112,8 +114,11 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { dailyStats.set(dateKey, stats) }) - const avgCarbsPerDay = Array.from(dailyStats.values()).reduce((sum, { totalCarbs, count }) => - sum + (totalCarbs / count), 0) / dailyStats.size || 0 + const avgCarbsPerDay = + Array.from(dailyStats.values()).reduce( + (sum, { totalCarbs, count }) => sum + totalCarbs / count, + 0 + ) / dailyStats.size || 0 return { total, @@ -168,7 +173,7 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { async function updateEntry(entry: MealHistoryEntry): Promise { try { - const index = entries.value.findIndex(e => e.id === entry.id) + const index = entries.value.findIndex((e) => e.id === entry.id) if (index === -1) return false const updatedEntry = { @@ -191,7 +196,7 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { async function deleteEntry(id: string): Promise { try { - const index = entries.value.findIndex(e => e.id === id) + const index = entries.value.findIndex((e) => e.id === id) if (index === -1) return false await db.removeMealHistory(id) @@ -206,7 +211,7 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { async function duplicateEntry(id: string): Promise { try { - const entry = entries.value.find(e => e.id === id) + const entry = entries.value.find((e) => e.id === id) if (!entry) return null const duplicate: MealHistoryEntry = { @@ -280,4 +285,4 @@ export const useMealHistoryStore = defineStore('mealHistoryStore', () => { setDateRange, loadInitialData } -}) \ No newline at end of file +}) diff --git a/src/stores/nutrientsFile.ts b/src/stores/nutrientsFile.ts index 256a937..7f513ef 100644 --- a/src/stores/nutrientsFile.ts +++ b/src/stores/nutrientsFile.ts @@ -117,7 +117,7 @@ export const useNutrientFileStore = defineStore('nutrientsFile', () => { function getNutrientById(id: number): NutrientFile | undefined { try { - return nutrientsFile.value.find(nutrient => nutrient.FoodID === id) + return nutrientsFile.value.find((nutrient) => nutrient.FoodID === id) } catch (error) { console.error('Failed to get nutrient by ID:', error) return undefined @@ -176,7 +176,7 @@ export const useNutrientFileStore = defineStore('nutrientsFile', () => { function getFavoriteNutrients(): NutrientFile[] { try { return favoriteNutrients.value - .map(id => getNutrientById(id)) + .map((id) => getNutrientById(id)) .filter((nutrient): nutrient is NutrientFile => nutrient !== undefined) } catch (error) { console.error('Failed to get favorite nutrients:', error) diff --git a/src/stores/session.ts b/src/stores/session.ts index d886c9d..3accef5 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -41,7 +41,9 @@ export const useUserSessionStore = defineStore('userSession', () => { // Getters (computed) const getUserSession = computed(() => userSession.value) - const hasExperimentNoticeBeenDismissed = computed(() => userSession.value.dismissedExperimentNotice) + const hasExperimentNoticeBeenDismissed = computed( + () => userSession.value.dismissedExperimentNotice + ) // Actions async function initialize() { diff --git a/src/stores/subject.ts b/src/stores/subject.ts index 9f6b286..55881f1 100644 --- a/src/stores/subject.ts +++ b/src/stores/subject.ts @@ -36,7 +36,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { console.log('Loaded active subject from user account:', activeSubjectId.value) } else if (subjects.value.length > 0) { // Set first active subject as default if none selected - activeSubjectId.value = subjects.value.find(s => s.active)?.id || subjects.value[0].id + activeSubjectId.value = subjects.value.find((s) => s.active)?.id || subjects.value[0].id console.log('Set first subject as active:', activeSubjectId.value) } } catch (err) { @@ -49,16 +49,14 @@ export const useSubjectStore = defineStore('subjectStore', () => { loadInitialData() // Getters - const activeSubjects = computed(() => - subjects.value.filter(subject => subject.active) - ) + const activeSubjects = computed(() => subjects.value.filter((subject) => subject.active)) const currentSubject = computed(() => - subjects.value.find(subject => subject.id === activeSubjectId.value) + subjects.value.find((subject) => subject.id === activeSubjectId.value) ) - const subjectById = computed(() => (id: string) => - subjects.value.find(subject => subject.id === id) + const subjectById = computed( + () => (id: string) => subjects.value.find((subject) => subject.id === id) ) const sortedSubjects = computed(() => @@ -102,7 +100,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { async function updateSubject(subject: Subject): Promise { try { - const index = subjects.value.findIndex(s => s.id === subject.id) + const index = subjects.value.findIndex((s) => s.id === subject.id) if (index === -1) return false const updatedSubject = { @@ -122,7 +120,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { async function deleteSubject(id: string): Promise { try { - const index = subjects.value.findIndex(s => s.id === id) + const index = subjects.value.findIndex((s) => s.id === id) if (index === -1) return false // Don't allow deleting the last subject @@ -132,7 +130,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { // If deleting active subject, switch to another one if (id === activeSubjectId.value) { - const newActive = subjects.value.find(s => s.id !== id && s.active) + const newActive = subjects.value.find((s) => s.id !== id && s.active) activeSubjectId.value = newActive?.id || subjects.value[0].id await updateUserPreference('defaultSubjectId', activeSubjectId.value) } @@ -149,7 +147,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { async function setActiveSubject(id: string): Promise { try { - const subject = subjects.value.find(s => s.id === id) + const subject = subjects.value.find((s) => s.id === id) if (!subject) return false activeSubjectId.value = id @@ -189,7 +187,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { settings: Partial ): Promise { try { - const subject = subjects.value.find(s => s.id === id) + const subject = subjects.value.find((s) => s.id === id) if (!subject) return false const updatedSubject = { @@ -202,7 +200,7 @@ export const useSubjectStore = defineStore('subjectStore', () => { } await db.saveSubject(updatedSubject) - const index = subjects.value.findIndex(s => s.id === id) + const index = subjects.value.findIndex((s) => s.id === id) subjects.value[index] = updatedSubject return true } catch (err) { @@ -232,4 +230,4 @@ export const useSubjectStore = defineStore('subjectStore', () => { updateSubjectSettings, loadInitialData } -}) \ No newline at end of file +}) diff --git a/src/types/meal-history.ts b/src/types/meal-history.ts index 197b698..7b43f1e 100644 --- a/src/types/meal-history.ts +++ b/src/types/meal-history.ts @@ -65,4 +65,4 @@ export interface Nutrient { name: string quantity: number factor: number -} \ No newline at end of file +} diff --git a/src/views/AboutView.vue b/src/views/AboutView.vue index eb562f5..459cdf8 100644 --- a/src/views/AboutView.vue +++ b/src/views/AboutView.vue @@ -1,15 +1,15 @@ diff --git a/src/views/CalculatorView.vue b/src/views/CalculatorView.vue index 63a767e..b6bf12b 100644 --- a/src/views/CalculatorView.vue +++ b/src/views/CalculatorView.vue @@ -8,4 +8,4 @@ import MealCalculator from '@/components/calculator/MealCalculator.vue'

{{ $t('notices.experimental.title') }}

- \ No newline at end of file + diff --git a/src/views/CarbFactor.vue b/src/views/CarbFactor.vue index 03a7feb..a306a26 100644 --- a/src/views/CarbFactor.vue +++ b/src/views/CarbFactor.vue @@ -8,4 +8,4 @@ import NutrientSearch from '@/components/search/NutrientSearch.vue'

{{ $t('notices.experimental.title') }}

- \ No newline at end of file + diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 4b85f60..b618e88 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -35,10 +35,11 @@ const navigateTo = (route: string) => {

{{ $t('components.search.title') }}

-

{{ $t('aboutText.para3') }} +

+ {{ $t('aboutText.para3') }} - {{ $t('aboutText.CNF') }} - . + {{ $t('aboutText.CNF') }} .