From 9fcd4d87e308da82feba72db41068cc8d27a4d05 Mon Sep 17 00:00:00 2001 From: iGor milhit Date: Sun, 11 Jan 2026 19:59:31 +0100 Subject: [PATCH] web: refactor in order to get an actual interface - Splits the app in pages, components and services. - Handles the routing. - Creates an API service. - Uses Pico CSS. - Validates the form. TODO: to add validation state to the form. - Updates the validation rules in the backend to be coherent on both sides. - Adds tests. Co-Authored-by: iGor milhit --- bread.py | 8 +- tests/test_bread.py | 12 - web/.env.development | 2 + web/.env.example | 2 + web/.gitignore | 10 + web/package-lock.json | 797 ++++++++++++++++++ web/package.json | 10 +- web/src/App.css | 49 -- web/src/App.jsx | 260 +----- web/src/App.test.jsx | 12 - web/src/components/BreadForm.jsx | 139 +++ web/src/components/BreadResult.jsx | 63 ++ web/src/components/ErrorMessage.jsx | 18 + web/src/components/Layout.jsx | 27 + web/src/components/Navigation.jsx | 40 + .../components/__tests__/BreadForm.test.jsx | 60 ++ .../components/__tests__/BreadResult.test.jsx | 57 ++ .../__tests__/ErrorMessage.test.jsx | 36 + web/src/main.jsx | 13 +- web/src/pages/About.jsx | 62 ++ web/src/pages/Calculator.jsx | 67 ++ web/src/pages/Home.jsx | 77 ++ web/src/pages/NotFound.jsx | 24 + web/src/services/__tests__/api.test.js | 108 +++ web/src/services/api.js | 71 ++ web/src/styles/_mixins.scss | 42 + web/src/styles/main.scss | 25 + web/src/test/setup.js | 3 + web/vite.config.js | 8 +- 29 files changed, 1792 insertions(+), 310 deletions(-) create mode 100644 web/.env.development create mode 100644 web/.env.example delete mode 100644 web/src/App.css delete mode 100644 web/src/App.test.jsx create mode 100644 web/src/components/BreadForm.jsx create mode 100644 web/src/components/BreadResult.jsx create mode 100644 web/src/components/ErrorMessage.jsx create mode 100644 web/src/components/Layout.jsx create mode 100644 web/src/components/Navigation.jsx create mode 100644 web/src/components/__tests__/BreadForm.test.jsx create mode 100644 web/src/components/__tests__/BreadResult.test.jsx create mode 100644 web/src/components/__tests__/ErrorMessage.test.jsx create mode 100644 web/src/pages/About.jsx create mode 100644 web/src/pages/Calculator.jsx create mode 100644 web/src/pages/Home.jsx create mode 100644 web/src/pages/NotFound.jsx create mode 100644 web/src/services/__tests__/api.test.js create mode 100644 web/src/services/api.js create mode 100644 web/src/styles/_mixins.scss create mode 100644 web/src/styles/main.scss create mode 100644 web/src/test/setup.js diff --git a/bread.py b/bread.py index 86653cb..ca22153 100644 --- a/bread.py +++ b/bread.py @@ -29,19 +29,19 @@ class BreadRecipe(BaseModel): All percentages follow baker's percentage (relative to total flour weight). """ # What the user provides - total_flour: float = Field(gt=0, description="Total flour weight in grams") + total_flour: float = Field(gt=1, description="Total flour weight in grams") hydration: float = Field( ge=50, le=100, description="Total hydration % (water/flour)" ) sourdough_percentage: float = Field( - ge=0, - le=50, + ge=10, + le=100, description="Sourdough as % of total flour weight" ) sourdough_ratio: SourdoughRatio = Field( description="Flour:water ratio in starter" ) - salt: float = Field(ge=0, le=5, description="Salt as % of total flour") + salt: float = Field(ge=0, le=2, description="Salt as % of total flour") # What Pydantic calculates automatically @computed_field diff --git a/tests/test_bread.py b/tests/test_bread.py index 97e722c..a176b49 100644 --- a/tests/test_bread.py +++ b/tests/test_bread.py @@ -108,15 +108,3 @@ class TestBreadRecipe: ) assert recipe.salt_weight == 10.0 - def test_no_sourdough_recipe(self): - """Test recipe without sourdough (0%).""" - recipe = BreadRecipe( - total_flour=500, - hydration=70, - sourdough_percentage=0, - sourdough_ratio=SourdoughRatio(flour_parts=1, water_parts=1), - salt=2 - ) - assert recipe.sourdough_weight == 0.0 - assert recipe.flour_to_add == 500.0 - assert recipe.water_to_add == 350.0 diff --git a/web/.env.development b/web/.env.development new file mode 100644 index 0000000..372f043 --- /dev/null +++ b/web/.env.development @@ -0,0 +1,2 @@ +# web/.env.development +VITE_API_URL=http://localhost:8000 diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..ede63aa --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +# API URL (required) +VITE_API_URL=http://localhost:8000 diff --git a/web/.gitignore b/web/.gitignore index 503bb51..5314e65 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -24,3 +24,13 @@ dist-ssr *.sw? .vite + +.env +.env.local +.env*.local +!.env.example +!.env.template +!env.development +!env.production + +coverage diff --git a/web/package-lock.json b/web/package-lock.json index 5960304..27ec3a6 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,16 +13,22 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@picocss/pico": "^2.1.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.16", + "@vitest/ui": "^4.0.16", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^27.4.0", + "react-router-dom": "^7.12.0", + "sass": "^1.97.2", "vite": "^7.2.4", "vitest": "^4.0.16" } @@ -389,6 +395,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -1245,6 +1261,330 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@picocss/pico": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@picocss/pico/-/pico-2.1.1.tgz", + "integrity": "sha512-kIDugA7Ps4U+2BHxiNHmvgPIQDWPDU4IeU6TNRdvXQM1uZX+FibqDQT2xUOnnO2yq/LUHcwnGlu1hvf4KfXnMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1643,6 +1983,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1770,6 +2124,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", @@ -1867,6 +2253,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", + "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "4.0.16", + "fflate": "^0.8.2", + "flatted": "^3.3.3", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.0.16" + } + }, "node_modules/@vitest/utils": { "version": "4.0.16", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", @@ -1985,6 +2394,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2023,6 +2451,20 @@ "concat-map": "0.0.1" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2116,6 +2558,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2150,6 +2608,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2264,6 +2736,20 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -2607,6 +3093,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2620,6 +3113,20 @@ "node": ">=16.0.0" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2749,6 +3256,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2787,6 +3301,13 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", + "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2847,6 +3368,17 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2861,6 +3393,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3046,6 +3632,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -3053,6 +3680,35 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -3076,6 +3732,16 @@ "node": "*" } }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3109,6 +3775,14 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -3368,6 +4042,60 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz", + "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz", + "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-router": "7.12.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3444,6 +4172,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/sass": { + "version": "1.97.2", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz", + "integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -3473,6 +4223,13 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3503,6 +4260,21 @@ "dev": true, "license": "ISC" }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3637,6 +4409,30 @@ "dev": true, "license": "MIT" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -3799,6 +4595,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", diff --git a/web/package.json b/web/package.json index 76b1963..42ca851 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,9 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "test": "vitest" + "test": "vitest", + "test:ui": "vitest --ui", + "test:coverage": "vitest --coverage" }, "dependencies": { "react": "^19.2.0", @@ -16,16 +18,22 @@ }, "devDependencies": { "@eslint/js": "^9.39.1", + "@picocss/pico": "^2.1.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^4.0.16", + "@vitest/ui": "^4.0.16", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "jsdom": "^27.4.0", + "react-router-dom": "^7.12.0", + "sass": "^1.97.2", "vite": "^7.2.4", "vitest": "^4.0.16" } diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index 4062ddb..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,49 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - /* text-align: center; */ -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} - -form, -label, -input, -button { - display: block; -} diff --git a/web/src/App.jsx b/web/src/App.jsx index 5845686..4609215 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,229 +1,39 @@ -import { useState } from "react"; -import "./App.css"; +// web/src/App.jsx +import { createBrowserRouter, RouterProvider } from 'react-router-dom' +import Layout from './components/Layout' +import Home from './pages/Home' +import About from "./pages/About"; +import Calculator from './pages/Calculator' +import NotFound from './pages/NotFound' + +// Define the routes +const router = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'about', + element: , + }, + { + path: 'calculator', + element: , + }, + { + path: '*', + element: , + }, + ], + }, +]) function App() { - const [formData, setFormData] = useState({ - total_flour: "", - hydration: "", - sourdough_percentage: "", - sourdough_flour_parts: "1", - sourdough_water_parts: "1", - salt: "", - }); - - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const handleChange = (e) => { - const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); - }; - - const handleSubmit = async (e) => { - e.preventDefault(); - setLoading(true); - setError(null); - - try { - // Transform form data to API format - const apiData = { - total_flour: parseFloat(formData.total_flour), - hydration: parseFloat(formData.hydration), - sourdough_percentage: parseFloat(formData.sourdough_percentage), - sourdough_ratio: { - flour_parts: parseFloat(formData.sourdough_flour_parts), - water_parts: parseFloat(formData.sourdough_water_parts), - }, - salt: parseFloat(formData.salt), - }; - - const response = await fetch("http://localhost:8000/calculate", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(apiData), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.detail || `HTTP error! status: ${response.status}` - ); - } - - const data = await response.json(); - setResult(data); - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - }; - - return ( -
-

Bread Dough Calculator

- -
-
- - -
- -
- - -
- -
- - -
- -
- Sourdough Ratio (Flour:Water) - -
- - -
- -
- - -
-
- -
- - -
- - -
- - {error && ( -
-

Error

-

{error}

-
- )} - - {result && ( -
-

Ingredients to Add

-
-
- Flour: - {result.ingredients_to_add.flour}g -
-
- Water: - {result.ingredients_to_add.water}g -
-
- Sourdough: - - {result.ingredients_to_add.sourdough}g - -
-
- Salt: - {result.ingredients_to_add.salt}g -
-
- -

Recipe Details

-
-
- Total flour: - {result.details.total_flour}g -
-
- Total water: - {result.details.total_water}g -
-
- Flour in sourdough: - - {result.details.flour_in_sourdough}g - -
-
- Water in sourdough: - - {result.details.water_in_sourdough}g - -
-
-
- )} -
- ); + return } -export default App; +export default App diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx deleted file mode 100644 index 2c5d00f..0000000 --- a/web/src/App.test.jsx +++ /dev/null @@ -1,12 +0,0 @@ -// web/src/App.test.jsx -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import App from './App' - -describe('App', () => { - it('affiche le formulaire', () => { - render() - expect(screen.getByLabelText(/farine/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /calculer/i })).toBeInTheDocument() - }) -}) diff --git a/web/src/components/BreadForm.jsx b/web/src/components/BreadForm.jsx new file mode 100644 index 0000000..4d57d75 --- /dev/null +++ b/web/src/components/BreadForm.jsx @@ -0,0 +1,139 @@ +// web/src/components/BreadForm.jsx + +function BreadForm({ onSubmit, loading }) { + return ( +
+
+ Les ingrédients de base + + + + + + +
+ +
+ Le levain + + + +

+ Pour calculer les quantités à ajouter au pâton pour respecter + le taux d'hydratation voulu, il faut connaître les proportions de + farine et d'eau du levain. C'est le rôle des champs suivant : +

+ + + + +
+ + +
+ ); +} + +export default BreadForm; diff --git a/web/src/components/BreadResult.jsx b/web/src/components/BreadResult.jsx new file mode 100644 index 0000000..e343517 --- /dev/null +++ b/web/src/components/BreadResult.jsx @@ -0,0 +1,63 @@ +// web/src/components/BreadResult.jsx + +function BreadResult({ result }) { + if (!result) { + return null; + } + + return ( + <> +
+
+ ✅ Ingrédients à ajouter +
+ + + + + + + + + + + + + + + + + + + +
Farine{result.ingredients_to_add.flour} g
Eau{result.ingredients_to_add.water} g
Levain{result.ingredients_to_add.sourdough} g
Sel{result.ingredients_to_add.salt} g
+ +
+ Détails de la recette + + + + + + + + + + + + + + + + + + + +
Farine totale{result.details.total_flour} g
Eau totale{result.details.total_water} g
Farine dans le levain{result.details.flour_in_sourdough} g
Eau dans le levain{result.details.water_in_sourdough} g
+
+
+ + ); +} + +export default BreadResult; diff --git a/web/src/components/ErrorMessage.jsx b/web/src/components/ErrorMessage.jsx new file mode 100644 index 0000000..5ce8fa0 --- /dev/null +++ b/web/src/components/ErrorMessage.jsx @@ -0,0 +1,18 @@ +// web/src/components/ErrorMessage.jsx + +function ErrorMessage({ error }) { + if (!error) { + return null; + } + + return ( +
+
+ ❌ Erreur +
+

{error}

+
+ ); +} + +export default ErrorMessage; diff --git a/web/src/components/Layout.jsx b/web/src/components/Layout.jsx new file mode 100644 index 0000000..87fd3f8 --- /dev/null +++ b/web/src/components/Layout.jsx @@ -0,0 +1,27 @@ +// web/src/components/Layout.jsx +import { Outlet } from 'react-router-dom' +import Navigation from './Navigation' + +function Layout() { + return ( + <> + + + +
+
+ + Fait avec ❤️ pour les amateurs et le amatrices de pain au levain • + + Source + + +
+ + ); +} + +export default Layout; diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx new file mode 100644 index 0000000..f1dd2a1 --- /dev/null +++ b/web/src/components/Navigation.jsx @@ -0,0 +1,40 @@ +// web/src/components/Navigation.jsx +import { Link } from 'react-router-dom' + +function Navigation() { + + return ( + + ); +} + +export default Navigation; diff --git a/web/src/components/__tests__/BreadForm.test.jsx b/web/src/components/__tests__/BreadForm.test.jsx new file mode 100644 index 0000000..f76fe12 --- /dev/null +++ b/web/src/components/__tests__/BreadForm.test.jsx @@ -0,0 +1,60 @@ +// web/src/components/__tests__/BreadForm.test.jsx +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import BreadForm from '../BreadForm' + +describe('BreadForm', () => { + it('should render all form fields', () => { + render() + + expect(screen.getByLabelText(/farine totale/i)).toBeInTheDocument() + expect(screen.getByLabelText(/hydratation/i)).toBeInTheDocument() + expect(screen.getByLabelText(/sel/i)).toBeInTheDocument() + expect(screen.getByLabelText(/levain.*%/i)).toBeInTheDocument() + expect(screen.getByLabelText(/ratio farine/i)).toBeInTheDocument() + expect(screen.getByLabelText(/ratio eau/i)).toBeInTheDocument() + }) + + it('should have default values', () => { + render() + + expect(screen.getByLabelText(/farine totale/i)).toHaveValue(500) + expect(screen.getByLabelText(/hydratation/i)).toHaveValue(60) + expect(screen.getByLabelText(/sel/i)).toHaveValue(1.6) + expect(screen.getByLabelText(/levain.*%/i)).toHaveValue(20) + }) + + it('should call onSubmit when form is submitted', async () => { + const user = userEvent.setup() + const handleSubmit = vi.fn((e) => e.preventDefault()) + + render() + + const form = screen.getByTestId('bread-form') + form.setAttribute('novalidate', '') + + const submitButton = screen.getByRole('button', { name: /calculer/i }) + await user.click(submitButton) + + expect(handleSubmit).toHaveBeenCalledTimes(1) + }) + + it('should disable submit button when loading', () => { + render() + + const submitButton = screen.getByRole('button') + expect(submitButton).toBeDisabled() + expect(submitButton).toHaveTextContent('Calcul en cours...') + }) + + it('should allow user to change input values', async () => { + const user = userEvent.setup() + render() + + const flourInput = screen.getByLabelText(/farine totale/i) + await user.clear(flourInput) + await user.type(flourInput, '1000') + + expect(flourInput).toHaveValue(1000) + }) +}) diff --git a/web/src/components/__tests__/BreadResult.test.jsx b/web/src/components/__tests__/BreadResult.test.jsx new file mode 100644 index 0000000..a31bc4c --- /dev/null +++ b/web/src/components/__tests__/BreadResult.test.jsx @@ -0,0 +1,57 @@ +// web/src/components/__tests__/BreadResult.test.jsx +import { render, screen } from '@testing-library/react' +import BreadResult from '../BreadResult' + +describe('BreadResult', () => { + const mockResult = { + ingredients_to_add: { + flour: 400, + water: 260, + sourdough: 100, + salt: 10, + }, + details: { + total_flour: 500, + total_water: 325, + flour_in_sourdough: 50, + water_in_sourdough: 50, + }, + } + + it('should not render when result is null', () => { + render() + + const article = screen.queryByRole('article') + expect(article).not.toBeInTheDocument() + }) + + it('should render ingredients to add', () => { + render() + + expect(screen.getByText('✅ Ingrédients à ajouter')).toBeInTheDocument() + expect(screen.getByText('400 g')).toBeInTheDocument() + expect(screen.getByText('260 g')).toBeInTheDocument() + expect(screen.getByText('100 g')).toBeInTheDocument() + expect(screen.getByText('10 g')).toBeInTheDocument() + }) + + it('should render details in collapsed state by default', () => { + render() + + const details = screen.getByText('Détails de la recette') + expect(details).toBeInTheDocument() + + // Les détails sont dans un
qui est fermé par défaut + const detailsElement = details.closest('details') + expect(detailsElement).not.toHaveAttribute('open') + }) + + it('should display all ingredient labels', () => { + render() + + expect(screen.getByText('Farine')).toBeInTheDocument() + expect(screen.getByText('Eau')).toBeInTheDocument() + expect(screen.getByText('Levain')).toBeInTheDocument() + expect(screen.getByText('Sel')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/__tests__/ErrorMessage.test.jsx b/web/src/components/__tests__/ErrorMessage.test.jsx new file mode 100644 index 0000000..4b29829 --- /dev/null +++ b/web/src/components/__tests__/ErrorMessage.test.jsx @@ -0,0 +1,36 @@ +// web/src/components/__tests__/ErrorMessage.test.jsx +import { render, screen } from '@testing-library/react' +import ErrorMessage from '../ErrorMessage' + +describe('ErrorMessage', () => { + it('should not render when error is null', () => { + render() + + const article = screen.queryByRole('article') + expect(article).not.toBeInTheDocument() + }) + + it('should not render when error is undefined', () => { + render() + + const article = screen.queryByRole('article') + expect(article).not.toBeInTheDocument() + }) + + it('should render error message when error is provided', () => { + const errorMessage = 'Une erreur est survenue' + render() + + expect(screen.getByText('❌ Erreur')).toBeInTheDocument() + expect(screen.getByText(errorMessage)).toBeInTheDocument() + }) + + it('should have correct styling for error', () => { + render() + + const article = screen.getByRole('article') + expect(article).toHaveStyle({ + backgroundColor: 'var(--pico-del-color)' + }) + }) +}) diff --git a/web/src/main.jsx b/web/src/main.jsx index b9a1a6d..c43f9fd 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -1,10 +1,11 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' +// web/src/main.jsx +import React from 'react' +import ReactDOM from 'react-dom/client' import App from './App.jsx' +import './styles/main.scss' // ✅ Import Pico + vos styles -createRoot(document.getElementById('root')).render( - +ReactDOM.createRoot(document.getElementById('root')).render( + - , + , ) diff --git a/web/src/pages/About.jsx b/web/src/pages/About.jsx new file mode 100644 index 0000000..b0b87ab --- /dev/null +++ b/web/src/pages/About.jsx @@ -0,0 +1,62 @@ +// web/src/pages/About.jsx + +function About() { + return ( + <> +
+
+

À propos

+
+
+
+

⚠️ Avertissement !

+
+

+ + Tout seul, je ne suis pas capable de développer une + application, même aussi simple ! + Aussi, j'utilise des modèles de langage proposés par les + assistants de Kagi. J'essaie + d'apprendre en faisant, mais le code est en grande partie + généré par la boîte noire. J'en porte l'entière + responsabilité, à défaut du mérite qui revient aux femmes + et aux hommes dont le travail a rendu cet outil possible. +

+

Non, ça n'excuse rien.

+
+
+
+

Histoire

+
+

+ J'avais un petit bout de code Python pour m'aider + à faire ces calculs. Je voulais simplement le mettre à + disposition. +

+

+ Par ailleurs, j'avais besoin de réaliser un petit projet pour + évaluer la faisabilité d'utiliser certains outils pour un autre + projet, plus conséquent. +

+

J'ai démarré ce projet.

+
+
+
+

+ Outils +

+
+

+

    +
  • FastAPI.
  • +
  • React et Vite.
  • +
  • Pico CSS.
  • +
+

+
+
+ + ); +} + +export default About; diff --git a/web/src/pages/Calculator.jsx b/web/src/pages/Calculator.jsx new file mode 100644 index 0000000..e070327 --- /dev/null +++ b/web/src/pages/Calculator.jsx @@ -0,0 +1,67 @@ +// web/src/pages/Calculator.jsx +import { useState } from "react"; +import BreadForm from "../components/BreadForm"; +import BreadResult from "../components/BreadResult"; +import ErrorMessage from "../components/ErrorMessage"; +import { calculateBread } from "../services/api"; + +function Calculator() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(null); + + const formData = new FormData(e.target); + + const params = { + total_flour: parseFloat(formData.get("total_flour")), + hydration: parseFloat(formData.get("hydration")), + sourdough_percentage: parseFloat(formData.get("sourdough_percentage")), + sourdough_ratio: { + flour_parts: parseFloat(formData.get("sourdough_flour_parts")), + water_parts: parseFloat(formData.get("sourdough_water_parts")), + }, + salt: parseFloat(formData.get("salt")), + }; + + try { + const data = await calculateBread(params); + setResult(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Calculatrice de pain

+

Calculez les proportions exactes pour votre recette

+
+
+ +
+ + + + + +
+
+ + + +
+ +
+ ); +} + +export default Calculator; diff --git a/web/src/pages/Home.jsx b/web/src/pages/Home.jsx new file mode 100644 index 0000000..12b0449 --- /dev/null +++ b/web/src/pages/Home.jsx @@ -0,0 +1,77 @@ +// web/src/pages/Home.jsx +import { Link } from 'react-router-dom' + +function Home() { + return ( + <> +
+
+

Une calculette pour votre pâton

+

+ Calculez les proportions des ingrédients pour votre + pain au levain. +

+
+
+ +
+
+
+
+

Pourquoi utiliser cette calculette ?

+

💦 Pour une hydratation précise.

+
+

+ Tenez-vous compte de la farine et de l'eau + contenues dans le levain que vous ajoutez à + votre pâton ? +

+

Non ?

+

+ Désormais, la calculette fait les calculs pour vous. +

+
+ +
+
+

Comment ça marche ?

+
+

Dans le formulaire :

+
    +
  1. + Indiquez la quantité de farine totale désirée + pour votre pain. +
  2. +
  3. + Choisissez votre taux d'hydratation (eau + en % de la farine totale). +
  4. +
  5. + + Définissez le ratio farine:eau de votre levain + . Un ratio de 1:1, signifie que votre + levain contient autant d'eau que de farine. +
  6. +
  7. + Ajoutez le sel (par exemple 1.6 % de + la farine, ou 16 g / kg). +
  8. +
  9. + 🎉 Obtenez les quantités exactes à utiliser pour + votre recette ! +
  10. +
+
+ + Commencer le calcul → + +
+
+ +
+ + + ); +} + +export default Home; diff --git a/web/src/pages/NotFound.jsx b/web/src/pages/NotFound.jsx new file mode 100644 index 0000000..2a21b21 --- /dev/null +++ b/web/src/pages/NotFound.jsx @@ -0,0 +1,24 @@ +// web/src/pages/NotFound.jsx +import { Link } from 'react-router-dom' + +function NotFound() { + return ( +
+
+
+

404 : page non trouvée

+
+

+ Désolé, la page que vous recherchez n'existe pas. +

+
+ + Retour à l'accueil + +
+
+
+ ); +} + +export default NotFound; diff --git a/web/src/services/__tests__/api.test.js b/web/src/services/__tests__/api.test.js new file mode 100644 index 0000000..44b5add --- /dev/null +++ b/web/src/services/__tests__/api.test.js @@ -0,0 +1,108 @@ +// web/src/services/__tests__/api.test.js +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { calculateBread, healthCheck } from '../api' + +describe('API Service', () => { + beforeEach(() => { + // Mock global fetch + global.fetch = vi.fn() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('calculateBread', () => { + it('should make POST request with correct data', async () => { + const mockResponse = { + ingredients_to_add: { + flour: 400, + water: 260, + sourdough: 100, + salt: 10, + }, + details: { + total_flour: 500, + total_water: 325, + flour_in_sourdough: 50, + water_in_sourdough: 50, + }, + } + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const params = { + total_flour: 500, + hydration: 65, + sourdough_percentage: 20, + sourdough_ratio: { + flour_parts: 1, + water_parts: 1, + }, + salt: 2, + } + + const result = await calculateBread(params) + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/calculate', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }) + ) + + expect(result).toEqual(mockResponse) + }) + + it('should throw error when API returns error', async () => { + global.fetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ detail: 'Invalid parameters' }), + }) + + await expect( + calculateBread({ total_flour: -1 }) + ).rejects.toThrow('Invalid parameters') + }) + + it('should throw network error when fetch fails', async () => { + global.fetch.mockRejectedValueOnce(new Error('Failed to fetch')) + + await expect( + calculateBread({ total_flour: 500 }) + ).rejects.toThrow(/Impossible de contacter le serveur/) + }) + }) + + describe('healthCheck', () => { + it('should make GET request to root endpoint', async () => { + const mockResponse = { status: 'ok' } + + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + }) + + const result = await healthCheck() + + expect(global.fetch).toHaveBeenCalledWith( + 'http://localhost:8000/', + expect.objectContaining({ + headers: { + 'Content-Type': 'application/json', + }, + }) + ) + + expect(result).toEqual(mockResponse) + }) + }) +}) diff --git a/web/src/services/api.js b/web/src/services/api.js new file mode 100644 index 0000000..07db16f --- /dev/null +++ b/web/src/services/api.js @@ -0,0 +1,71 @@ +// web/src/services/api.js + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +/** + * Generic API request handler + * @param {string} endpoint - API endpoint (e.g., '/calculate') + * @param {object} options - Fetch options + * @returns {Promise} - Response data + * @throws {Error} - API error with message + */ +async function apiRequest(endpoint, options = {}) { + const url = `${API_URL}${endpoint}`; + + const config = { + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + ...options, + }; + + try { + const response = await fetch(url, config); + + // Handle HTTP errors + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.detail || + errorData.message || + `HTTP error! status: ${response.status}` + ); + } + + return await response.json(); + } catch (error) { + // Re-throw with more context if it's a network error + if (error.message === 'Failed to fetch') { + throw new Error( + `Impossible de contacter le serveur. Vérifiez que l'API est démarrée sur ${API_URL}` + ); + } + throw error; + } +} + +/** + * Calculate bread proportions + * @param {object} params - Bread calculation parameters + * @param {number} params.total_flour - Total flour in grams + * @param {number} params.hydration - Hydration percentage + * @param {number} params.sourdough_percentage - Sourdough percentage + * @param {object} params.sourdough_ratio - Sourdough flour:water ratio + * @param {number} params.salt - Salt percentage + * @returns {Promise} - Calculation results + */ +export async function calculateBread(params) { + return apiRequest('/calculate', { + method: 'POST', + body: JSON.stringify(params), + }); +} + +/** + * Health check endpoint + * @returns {Promise} - API status + */ +export async function healthCheck() { + return apiRequest('/'); +} diff --git a/web/src/styles/_mixins.scss b/web/src/styles/_mixins.scss new file mode 100644 index 0000000..adef49d --- /dev/null +++ b/web/src/styles/_mixins.scss @@ -0,0 +1,42 @@ +// web/src/styles/_mixins.scss + +@use "sass:map"; +@use "@picocss/pico/scss/pico" as pico; + + +/// Mixin for media queries based on Pico's breakpoints +/// @param {String} $size - breakpoint name (sm, md, lg, xl, xxl) +/// @example +/// @include breakpoint(md) { +/// font-size: 1.5rem; +/// } +@mixin breakpoint($size) { + $breakpoint: map.get(pico.$breakpoints, $size, "breakpoint"); + + @if $breakpoint { + @media (min-width: $breakpoint) { + @content; + } + } @else { + @error "Breakpoint '#{$size}' not found. Available values: sm, md, lg, xl, xxl"; + } +} + + +/// Mixin for media queries min-width based on Pico's breakpoints +/// @param {String} $size - breakpoint name (sm, md, lg, xl, xxl) +/// @example +/// @include breakpoint-up(md) { +/// font-size: 1.5rem; +/// } +@mixin breakpoint-up($size) { + $breakpoint: map.get(pico.$breakpoints, $size, "breakpoint"); + + @if $breakpoint { + @media (min-width: $breakpoint) { + @content; + } + } @else { + @error "Breakpoint '#{$size}' not found. Available values: sm, md, lg, xl, xxl"; + } +} diff --git a/web/src/styles/main.scss b/web/src/styles/main.scss new file mode 100644 index 0000000..f460a1a --- /dev/null +++ b/web/src/styles/main.scss @@ -0,0 +1,25 @@ +// web/src/styles/main.scss + +// Import Pico CSS with default values, except for the color palette. +@use "@picocss/pico/scss/pico" as * with ( + $theme-color: "purple", + $semantic-root-element: "#root", + $enable-semantic-container: true +); +@use "mixins" as *; + +#root > footer { + text-align: center; +} + +.error_message { + background-color: var(--pico-del-color); +} + +.bread-form-result { + // Rules apply on large screens and bigger + @include breakpoint-up(lg) { + max-width: 50dvi; + margin: 0 auto; + } +} diff --git a/web/src/test/setup.js b/web/src/test/setup.js new file mode 100644 index 0000000..4569fba --- /dev/null +++ b/web/src/test/setup.js @@ -0,0 +1,3 @@ +// web/src/test/setup.js +import '@testing-library/jest-dom' + diff --git a/web/vite.config.js b/web/vite.config.js index 8b0f57b..b71fcd9 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -1,7 +1,13 @@ +// web/vite.config.js import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ export default defineConfig({ plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.js', + css: true, + }, })