diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..46f15e9 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 80, + "endOfLine": "auto" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..d7df89c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..519b35f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.workingDirectories": ["./client", "./backend"], + "[markdown]": { + "editor.formatOnSave": false + } +} diff --git a/README.md b/README.md index 43e1a49..cb2221d 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,7 @@ type WineMatch { | Backend | Node.js, TypeScript, Apollo Server | GraphQL er krav i oppgaven, og Apollo Server er godt dokumentert | | Database | PostgreSQL | Relasjonsdatabase som passer godt til viner, brukere og favoritter. Støtter også effektiv søking, filtrering og sortering | | Testing | Vitest | Vitest er krav i oppgaven | -| Kodekvalitet | oxlint og Prettier | Linting og felles formatering | +| Kodekvalitet | ESLint og Prettier | ESLint finner feil og feil bruk av React-hooks, og Prettier gir lik formatering i hele prosjektet. ESLint fulgte med Vite-malen, så vi bruker det samme oppsettet i backend | @@ -553,6 +553,26 @@ npm run dev Åpne http://localhost:5173/project2/. Frontend henter data fra http://localhost:3001/. En annen adresse kan settes med `VITE_GRAPHQL_URL` (se `client/.env.example`). +### Linting og formatering + +Hele prosjektet bruker ESLint og Prettier, med samme oppsett i `client/` og `backend/`: + +- **ESLint** finner feil og risikable mønstre i koden, for eksempel ubrukte variabler og feil bruk av React-hooks. Oppsettet ligger i `eslint.config.js` i hver mappe. +- **Prettier** formaterer koden likt for alle, så kodegjennomgang handler om innholdet og ikke om mellomrom og anførselstegn. Reglene ligger i `.prettierrc.json` i rotmappen og gjelder begge mappene. +- **eslint-config-prettier** slår av ESLint-reglene som ellers ville vært uenige med Prettier. + +Kommandoene kjøres i `client/` eller `backend/`: + +| Kommando | Hva den gjør | +|---|---| +| `npm run lint` | Finner feil med ESLint | +| `npm run format` | Formaterer alle filene med Prettier | +| `npm run format:check` | Sjekker formateringen uten å endre noe | + +Repoet har VS Code-innstillinger i `.vscode/` som formaterer med Prettier hver gang du lagrer, og retter det ESLint kan rette automatisk. VS Code foreslår utvidelsene Prettier og ESLint når du åpner prosjektet. Kjør `npm run lint` og `npm run format:check` i begge mappene før du lager en pull request. + +Backend bruker TypeScript 6, samme versjon som klienten, fordi typescript-eslint ennå ikke støtter TypeScript 7. + ### Kjøring på VM-en Applikasjonen kjører på http://it2810-01.idi.ntnu.no/project2, og GraphQL-backend på http://it2810-01.idi.ntnu.no:3001/. VM-en kan bare nås fra NTNU-nettet eller via VPN. Hvilken commit som kjører, står i http://it2810-01.idi.ntnu.no/project2/version.txt. diff --git a/backend/.prettierignore b/backend/.prettierignore new file mode 100644 index 0000000..0cb1fb6 --- /dev/null +++ b/backend/.prettierignore @@ -0,0 +1,5 @@ +# Build output and the X-Wines data are not our code. +dist +data +# Generated by npm, so it should not be reformatted. +package-lock.json diff --git a/backend/db/migrations/004_add_winery_search_index.sql b/backend/db/migrations/004_add_winery_search_index.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/eslint.config.js b/backend/eslint.config.js new file mode 100644 index 0000000..689f5d8 --- /dev/null +++ b/backend/eslint.config.js @@ -0,0 +1,21 @@ +// ESLint finds bugs and risky patterns in the backend code. +// Formatting is left to Prettier: eslint-config-prettier (last in the list) +// turns off the ESLint rules that would disagree with it. +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores(['dist', 'data']), + { + files: ['**/*.ts'], + extends: [js.configs.recommended, tseslint.configs.recommended], + languageOptions: { + // The backend runs in Node.js, not in a browser + globals: globals.node, + }, + }, + eslintConfigPrettier, +]); diff --git a/backend/package-lock.json b/backend/package-lock.json index 1e0229a..a26751f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,10 +16,16 @@ "pg": "^8.23.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^26.6.4", "@types/pg": "^8.23.1", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.13.0", + "prettier": "^3.9.10", "tsx": "^4.23.15", - "typescript": "^7.0.2" + "typescript": "~6.0.2", + "typescript-eslint": "^8.71.2" } }, "node_modules/@apollo/cache-control-types": { @@ -259,6 +265,30 @@ "node": ">=16" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -701,6 +731,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@graphql-tools/merge": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.6.tgz", @@ -761,6 +919,96 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -824,6 +1072,27 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -852,356 +1121,286 @@ "pg-types": "^2.2.0" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">= 4" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, "engines": { - "node": ">=16.20.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], + "node_modules/@whatwg-node/promise-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", + "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, "engines": { - "node": ">=16.20.0" + "node": ">=22.15.0" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { - "node": ">=16.20.0" + "node": ">=0.4.0" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@whatwg-node/promise-helpers": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", - "integrity": "sha512-/i9CS0Z96o+cPwaYoAyVxAThDiUX1wV3tDmNWPJbn+vtdR+JftuCDg1NGCXBrzIYVDGkJ8uT6ctwmrBOUzOpKQ==", "license": "MIT", "dependencies": { - "tslib": "^2.6.3" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": ">=22.15.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/async-retry": { @@ -1228,6 +1427,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1265,6 +1474,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1274,6 +1496,20 @@ "node": ">= 0.8" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -1359,6 +1595,21 @@ "node": ">=16.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csv-parse": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-7.0.3.tgz", @@ -1382,6 +1633,13 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1530,6 +1788,239 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1551,6 +2042,42 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -1614,17 +2141,43 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/gopd": { @@ -1687,6 +2240,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -1699,6 +2265,13 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1735,6 +2308,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1753,6 +2346,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -1774,6 +2390,67 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -1855,12 +2532,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -1923,6 +2623,56 @@ "node": ">= 0.8" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1932,6 +2682,26 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pg": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", @@ -2021,6 +2791,19 @@ "split2": "^4.1.0" } }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -2069,6 +2852,62 @@ "node": ">=0.10.0" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -2135,6 +2974,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -2178,6 +3030,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -2268,6 +3143,23 @@ "node": ">= 0.8" } }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -2291,6 +3183,19 @@ "node": ">=0.6" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -2316,6 +3221,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -2362,38 +3280,41 @@ } }, "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { - "tsc": "bin/tsc" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=16.20.0" + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz", + "integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2" }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici-types": { @@ -2412,6 +3333,16 @@ "node": ">= 0.8" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2430,6 +3361,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-typed-array": { "version": "1.1.24", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", @@ -2451,6 +3398,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -2459,6 +3416,19 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/backend/package.json b/backend/package.json index 1abc594..b728e05 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,10 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "import": "tsx scripts/import.ts" + "import": "tsx scripts/import.ts", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "keywords": [], "author": "", @@ -21,9 +24,15 @@ "pg": "^8.23.1" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@types/node": "^26.6.4", "@types/pg": "^8.23.1", + "eslint": "^10.12.0", + "eslint-config-prettier": "^10.1.8", + "globals": "^17.13.0", + "prettier": "^3.9.10", "tsx": "^4.23.15", - "typescript": "^7.0.2" + "typescript": "~6.0.2", + "typescript-eslint": "^8.71.2" } } diff --git a/backend/scripts/import.ts b/backend/scripts/import.ts index bfacfe3..962814b 100644 --- a/backend/scripts/import.ts +++ b/backend/scripts/import.ts @@ -6,25 +6,25 @@ // // Skriptet kan kjøres flere ganger. Viner oppdateres, og favoritter beholdes. -import { createReadStream, readFileSync } from "node:fs"; -import { parse } from "csv-parse"; -import type { PoolClient } from "pg"; -import { pool } from "../src/db.js"; +import { createReadStream, readFileSync } from 'node:fs'; +import { parse } from 'csv-parse'; +import type { PoolClient } from 'pg'; +import { pool } from '../src/db.js'; -const WINES_FILE = process.argv[2] ?? "data/XWines_Test_100_wines.csv"; -const RATINGS_FILE = process.argv[3] ?? "data/XWines_Test_1K_ratings.csv"; +const WINES_FILE = process.argv[2] ?? 'data/XWines_Test_100_wines.csv'; +const RATINGS_FILE = process.argv[3] ?? 'data/XWines_Test_1K_ratings.csv'; const BATCH_SIZE = 1000; -const FOOD_CATEGORIES_FILE = "db/seeds/food_categories.sql"; +const FOOD_CATEGORIES_FILE = 'db/seeds/food_categories.sql'; const CURRENT_YEAR = new Date().getFullYear(); const BODY_LEVELS = new Set([ - "Very light-bodied", - "Light-bodied", - "Medium-bodied", - "Full-bodied", - "Very full-bodied", + 'Very light-bodied', + 'Light-bodied', + 'Medium-bodied', + 'Full-bodied', + 'Very full-bodied', ]); -const ACIDITY_LEVELS = new Set(["Low", "Medium", "High"]); +const ACIDITY_LEVELS = new Set(['Low', 'Medium', 'High']); type CsvRow = Record; @@ -48,16 +48,17 @@ interface Wine { vintages: number[]; } -type Validation = { ok: true; wine: Wine; warnings: string[] } | { ok: false; reason: string }; +type Validation = + { ok: true; wine: Wine; warnings: string[] } | { ok: false; reason: string }; // ---------- Hjelpefunksjoner for rensing ---------- function text(value: string | undefined): string { - return (value ?? "").trim(); + return (value ?? '').trim(); } function orNull(value: string): string | null { - return value === "" ? null : value; + return value === '' ? null : value; } function toId(value: string | undefined): number | null { @@ -69,8 +70,8 @@ function toId(value: string | undefined): number | null { function parseList(value: string): string[] { const items: string[] = []; for (const match of value.matchAll(/'([^']*)'|"([^"]*)"/g)) { - const item = (match[1] ?? match[2] ?? "").trim(); - if (item !== "") items.push(item); + const item = (match[1] ?? match[2] ?? '').trim(); + if (item !== '') items.push(item); } return [...new Set(items)]; } @@ -90,45 +91,45 @@ function validateWine(row: CsvRow): Validation { const warnings: string[] = []; const id = toId(row.WineID); - if (id === null) return fail("ugyldig WineID"); + if (id === null) return fail('ugyldig WineID'); const name = text(row.WineName); - if (name === "") return fail("mangler WineName"); + if (name === '') return fail('mangler WineName'); const type = text(row.Type); - if (type === "") return fail("mangler Type"); + if (type === '') return fail('mangler Type'); const wineryId = toId(row.WineryID); const wineryName = text(row.WineryName); - if (wineryId === null || wineryName === "") return fail("mangler produsent"); + if (wineryId === null || wineryName === '') return fail('mangler produsent'); const regionId = toId(row.RegionID); const regionName = text(row.RegionName); - if (regionId === null || regionName === "") return fail("mangler region"); + if (regionId === null || regionName === '') return fail('mangler region'); const countryCode = text(row.Code).toUpperCase(); const countryName = text(row.Country); - if (countryCode === "" || countryName === "") return fail("mangler land"); + if (countryCode === '' || countryName === '') return fail('mangler land'); // Valgfrie felt: ugyldig verdi blir NULL, vinen beholdes. const abvText = text(row.ABV); const abvNumber = Number(abvText); let abv: number | null = null; - if (abvText !== "") { + if (abvText !== '') { if (Number.isFinite(abvNumber) && abvNumber > 0 && abvNumber <= 50) { abv = Math.round(abvNumber * 10) / 10; } else { - warnings.push("ugyldig ABV"); + warnings.push('ugyldig ABV'); } } const bodyText = text(row.Body); const body = BODY_LEVELS.has(bodyText) ? bodyText : null; - if (bodyText !== "" && body === null) warnings.push("ugyldig Body"); + if (bodyText !== '' && body === null) warnings.push('ugyldig Body'); const acidityText = text(row.Acidity); const acidity = ACIDITY_LEVELS.has(acidityText) ? acidityText : null; - if (acidityText !== "" && acidity === null) warnings.push("ugyldig Acidity"); + if (acidityText !== '' && acidity === null) warnings.push('ugyldig Acidity'); return { ok: true, @@ -169,15 +170,21 @@ async function readWines() { const examples: string[] = []; let line = 1; // linje 1 er overskriften - const parser = createReadStream(WINES_FILE).pipe(parse({ columns: true, relax_column_count: true })); + const parser = createReadStream(WINES_FILE).pipe( + parse({ columns: true, relax_column_count: true }), + ); for await (const row of parser as AsyncIterable) { line++; let result = validateWine(row); - if (result.ok && seenIds.has(result.wine.id)) result = { ok: false, reason: "duplikat WineID" }; + if (result.ok && seenIds.has(result.wine.id)) + result = { ok: false, reason: 'duplikat WineID' }; if (!result.ok) { countReason(skipped, result.reason); - if (examples.length < 10) examples.push(`linje ${line}: ${result.reason} (WineID=${text(row.WineID) || "tom"})`); + if (examples.length < 10) + examples.push( + `linje ${line}: ${result.reason} (WineID=${text(row.WineID) || 'tom'})`, + ); continue; } @@ -194,20 +201,23 @@ async function readRatings(wineIds: Set) { const skipped = new Map(); let read = 0; - const parser = createReadStream(RATINGS_FILE).pipe(parse({ columns: true, relax_column_count: true })); + const parser = createReadStream(RATINGS_FILE).pipe( + parse({ columns: true, relax_column_count: true }), + ); for await (const row of parser as AsyncIterable) { read++; - if (read % 1_000_000 === 0) console.log(` ... ${read.toLocaleString("nb-NO")} vurderinger lest`); + if (read % 1_000_000 === 0) + console.log(` ... ${read.toLocaleString('nb-NO')} vurderinger lest`); const wineId = toId(row.WineID); const rating = Number(text(row.Rating)); if (wineId === null || !wineIds.has(wineId)) { - countReason(skipped, "ukjent vin"); + countReason(skipped, 'ukjent vin'); continue; } if (!Number.isFinite(rating) || rating < 1 || rating > 5) { - countReason(skipped, "ugyldig rating"); + countReason(skipped, 'ugyldig rating'); continue; } @@ -223,7 +233,11 @@ async function readRatings(wineIds: Set) { // ---------- Skriving til databasen ---------- // Sender radene som JSON i bolker på BATCH_SIZE, i stedet for én spørring per rad. -async function insertInBatches(client: PoolClient, sql: string, rows: object[]) { +async function insertInBatches( + client: PoolClient, + sql: string, + rows: object[], +) { for (let i = 0; i < rows.length; i += BATCH_SIZE) { await client.query(sql, [JSON.stringify(rows.slice(i, i + BATCH_SIZE))]); } @@ -235,14 +249,21 @@ function uniqueBy(items: T[], key: (item: T) => string | number): T[] { return [...map.values()]; } -async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map) { +async function writeToDatabase( + client: PoolClient, + wines: Wine[], + totals: Map, +) { // 1. Oppslagstabellene, i den rekkefølgen fremmednøklene krever await insertInBatches( client, `INSERT INTO countries (code, name) SELECT code, name FROM jsonb_to_recordset($1::jsonb) AS x(code text, name text) ON CONFLICT DO NOTHING`, - uniqueBy(wines, (w) => w.countryCode).map((w) => ({ code: w.countryCode, name: w.countryName })) + uniqueBy(wines, (w) => w.countryCode).map((w) => ({ + code: w.countryCode, + name: w.countryName, + })), ); await insertInBatches( @@ -250,7 +271,11 @@ async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map w.regionId).map((w) => ({ id: w.regionId, name: w.regionName, country_code: w.countryCode })) + uniqueBy(wines, (w) => w.regionId).map((w) => ({ + id: w.regionId, + name: w.regionName, + country_code: w.countryCode, + })), ); await insertInBatches( @@ -258,7 +283,11 @@ async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map w.wineryId).map((w) => ({ id: w.wineryId, name: w.wineryName, website: w.website })) + uniqueBy(wines, (w) => w.wineryId).map((w) => ({ + id: w.wineryId, + name: w.wineryName, + website: w.website, + })), ); // 2. Vinene. ON CONFLICT DO UPDATE gjør at en ny kjøring oppdaterer vinene @@ -286,22 +315,26 @@ async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map w.grapes.map((name) => ({ wine_id: w.id, name }))); - const foodLinks = wines.flatMap((w) => w.foods.map((name) => ({ wine_id: w.id, name }))); + const grapeLinks = wines.flatMap((w) => + w.grapes.map((name) => ({ wine_id: w.id, name })), + ); + const foodLinks = wines.flatMap((w) => + w.foods.map((name) => ({ wine_id: w.id, name })), + ); await insertInBatches( client, `INSERT INTO grapes (name) SELECT name FROM jsonb_to_recordset($1::jsonb) AS x(name text) ON CONFLICT DO NOTHING`, - uniqueBy(grapeLinks, (l) => l.name).map((l) => ({ name: l.name })) + uniqueBy(grapeLinks, (l) => l.name).map((l) => ({ name: l.name })), ); await insertInBatches( client, `INSERT INTO foods (name) SELECT name FROM jsonb_to_recordset($1::jsonb) AS x(name text) ON CONFLICT DO NOTHING`, - uniqueBy(foodLinks, (l) => l.name).map((l) => ({ name: l.name })) + uniqueBy(foodLinks, (l) => l.name).map((l) => ({ name: l.name })), ); await insertInBatches( @@ -310,7 +343,7 @@ async function writeToDatabase(client: PoolClient, wines: Wine[], totals: Map ({ id, sum: t.sum, count: t.count })) + [...totals].map(([id, t]) => ({ id, sum: t.sum, count: t.count })), ); // 5. Matkategorier: grupperingen ligger i en egen SQL-fil, så den er lett å endre - await client.query(readFileSync(FOOD_CATEGORIES_FILE, "utf8")); + await client.query(readFileSync(FOOD_CATEGORIES_FILE, 'utf8')); return { grapeLinks: grapeLinks.length, foodLinks: foodLinks.length }; } @@ -354,41 +387,46 @@ async function main() { console.log(`Leser vurderinger fra ${RATINGS_FILE}`); const ratings = await readRatings(new Set(wines.map((w) => w.id))); - console.log(` ${ratings.read} vurderinger lest, ${ratings.totals.size} viner har rating`); + console.log( + ` ${ratings.read} vurderinger lest, ${ratings.totals.size} viner har rating`, + ); - console.log("Skriver til databasen"); + console.log('Skriver til databasen'); const client = await pool.connect(); try { - await client.query("BEGIN"); + await client.query('BEGIN'); const links = await writeToDatabase(client, wines, ratings.totals); - await client.query("COMMIT"); - console.log(` ${links.grapeLinks} drue-koblinger og ${links.foodLinks} mat-koblinger`); + await client.query('COMMIT'); + console.log( + ` ${links.grapeLinks} drue-koblinger og ${links.foodLinks} mat-koblinger`, + ); } catch (error) { - await client.query("ROLLBACK"); - console.error("Importen feilet, ingenting ble lagret."); + await client.query('ROLLBACK'); + console.error('Importen feilet, ingenting ble lagret.'); throw error; } finally { client.release(); } - console.log(""); - printCounts("Viner som ble hoppet over:", skipped); + console.log(''); + printCounts('Viner som ble hoppet over:', skipped); if (examples.length > 0) { - console.log("Eksempler:"); + console.log('Eksempler:'); for (const example of examples) console.log(` ${example}`); } - printCounts("Felt satt til NULL på grunn av ugyldig verdi:", warnings); - printCounts("Vurderinger som ble hoppet over:", ratings.skipped); + printCounts('Felt satt til NULL på grunn av ugyldig verdi:', warnings); + printCounts('Vurderinger som ble hoppet over:', ratings.skipped); const uncategorized = await pool.query( `SELECT f.name, count(*)::int AS wines FROM foods f JOIN wine_foods wf ON wf.food_id = f.id WHERE f.category IS NULL - GROUP BY f.name ORDER BY wines DESC` + GROUP BY f.name ORDER BY wines DESC`, ); if (uncategorized.rows.length > 0) { console.log(`Mat uten kategori (legg dem til i ${FOOD_CATEGORIES_FILE}):`); - for (const row of uncategorized.rows) console.log(` ${row.name}: ${row.wines} viner`); + for (const row of uncategorized.rows) + console.log(` ${row.name}: ${row.wines} viner`); } console.log(`Ferdig på ${((Date.now() - started) / 1000).toFixed(1)} s`); } @@ -398,4 +436,4 @@ main() console.error(error); process.exitCode = 1; }) - .finally(() => pool.end()); \ No newline at end of file + .finally(() => pool.end()); diff --git a/backend/src/db.ts b/backend/src/db.ts index 46ab7b4..dbcdc46 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -1,5 +1,5 @@ -import pg from "pg"; -import "dotenv/config"; +import pg from 'pg'; +import 'dotenv/config'; const { Pool } = pg; @@ -9,4 +9,4 @@ export const pool = new Pool({ database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, -}); \ No newline at end of file +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 8e06108..a06f4b6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,7 +1,7 @@ -import { ApolloServer } from "@apollo/server"; -import { startStandaloneServer } from "@apollo/server/standalone"; -import {GraphQLError} from "graphql"; -import { pool } from "./db.js"; +import { ApolloServer } from '@apollo/server'; +import { startStandaloneServer } from '@apollo/server/standalone'; +import { GraphQLError } from 'graphql'; +import { pool } from './db.js'; const MAX_LIMIT = 100; @@ -10,18 +10,181 @@ function readPaging(args: { limit?: number; offset?: number }) { const offset = args.offset ?? 0; if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) { - throw new GraphQLError(`limit må være mellom 1 og ${MAX_LIMIT}`, { - extensions: { code: "BAD_USER_INPUT" }, + throw new GraphQLError(`limit must be between 1 and ${MAX_LIMIT}`, { + extensions: { code: 'BAD_USER_INPUT' }, }); } if (!Number.isInteger(offset) || offset < 0) { - throw new GraphQLError("offset kan ikke være negativ", { - extensions: { code: "BAD_USER_INPUT" }, + throw new GraphQLError('offset cannot be negative', { + extensions: { code: 'BAD_USER_INPUT' }, }); } return { limit, offset }; } +// Smakskartet viser prikker, ikke sider, så det får en egen grense. +// Flere enn ca. 300 prikker blir uleselig og tungt å tegne. +const TASTE_MAP_MAX = 300; + +// Find wine by food: viner med minst så mange vurderinger kommer først. +// Ellers havner ukjente viner med én vurdering øverst, fordi de tilfeldigvis +// passer til flest retter i kategorien. +const FOOD_POPULAR_MIN = 50; + +/** + * ORDER BY for hver sortering i vinkatalogen. + * Klienten sender bare navnet (enum WineSort), og SQL-en hentes herfra, + * så brukerinput havner aldri direkte i spørringen. + * w.id til slutt gir stabil rekkefølge ved like verdier, så paginering + * ikke hopper over eller gjentar viner. Viner uten verdi havner sist. + */ +const WINE_ORDER = { + NAME_ASC: 'w.name ASC, w.id', + NAME_DESC: 'w.name DESC, w.id', + // Ved lik rating kommer vinene flest har vurdert først + RATING_DESC: 'w.avg_rating DESC NULLS LAST, w.rating_count DESC, w.id', + RATING_ASC: 'w.avg_rating ASC NULLS LAST, w.rating_count DESC, w.id', + ABV_DESC: 'w.abv DESC NULLS LAST, w.id', + ABV_ASC: 'w.abv ASC NULLS LAST, w.id', +} as const; + +type WineSort = keyof typeof WINE_ORDER; + +// Samme verdier som typene body_level og acidity_level i databasen. +// Ukjente verdier avvises her, ellers gir PostgreSQL en intern feil. +const BODY_LEVELS = [ + 'Very light-bodied', + 'Light-bodied', + 'Medium-bodied', + 'Full-bodied', + 'Very full-bodied', +]; +const ACIDITY_LEVELS = ['Low', 'Medium', 'High']; + +type WineFilter = { + type?: string | null; + country?: string | null; + region?: string | null; + grape?: string | null; + body?: string | null; + acidity?: string | null; + minRatingCount?: number | null; +}; + +function badInput(message: string) { + return new GraphQLError(message, { extensions: { code: 'BAD_USER_INPUT' } }); +} + +/** + * Lager WHERE-delen for vinkatalogen ut fra søk og filtre. + * Samme mønster som tasteWhere: verdiene sendes alltid som parametere. + * Både vinlisten og tellingen bruker denne, så totalCount stemmer med listen. + * Spørringen må ha w (wines) og wi (wineries) med, fordi søket ser på produsent. + */ +function catalogWhere(search: string | null, filter: WineFilter = {}) { + const where: string[] = []; + const params: unknown[] = []; + const add = (sql: string, value: unknown) => { + params.push(value); + where.push(sql.replaceAll('?', `$${params.length}`)); + }; + + if (search) { + // Treff i starten av et ord i vinnavnet eller produsentnavnet + add( + `( + w.name ILIKE ?::text || '%' + OR w.name ILIKE '% ' || ?::text || '%' + OR wi.name ILIKE ?::text || '%' + OR wi.name ILIKE '% ' || ?::text || '%' + )`, + search, + ); + } + if (filter.type) add('w.type = ?', filter.type); + // Landkoden ligger på wines, så landfilteret trenger ingen join + if (filter.country) add('w.country_code = ?', filter.country); + if (filter.region) { + const regionId = Number(filter.region); + if (!Number.isInteger(regionId)) throw badInput('region must be an id'); + add('w.region_id = ?', regionId); + } + if (filter.grape) { + add( + `EXISTS ( + SELECT 1 FROM wine_grapes wg + JOIN grapes g ON g.id = wg.grape_id + WHERE wg.wine_id = w.id AND g.name = ? + )`, + filter.grape, + ); + } + if (filter.body) { + if (!BODY_LEVELS.includes(filter.body)) throw badInput('unknown body'); + add('w.body = ?', filter.body); + } + if (filter.acidity) { + if (!ACIDITY_LEVELS.includes(filter.acidity)) { + throw badInput('unknown acidity'); + } + add('w.acidity = ?', filter.acidity); + } + // Gjør sortering på rating nyttig: uten dette kommer viner med + // én enkelt vurdering på 5.0 først. + if (filter.minRatingCount != null) { + if (!Number.isInteger(filter.minRatingCount) || filter.minRatingCount < 0) { + throw badInput('minRatingCount cannot be negative'); + } + add('w.rating_count >= ?', filter.minRatingCount); + } + + return { sql: where.join(' AND ') || 'TRUE', params }; +} + +type TasteFilter = { + type?: string | null; + grape?: string | null; + country?: string | null; + year?: number | null; + minRating?: number | null; + minRatingCount?: number | null; +}; + +/** + * Lager WHERE-delen for smakskartet ut fra filtrene. + * Verdiene sendes alltid som parametere ($1, $2, ...), aldri rett inn i SQL, + * så brukerinput kan ikke endre spørringen (SQL-injeksjon). + */ +function tasteWhere(filter: TasteFilter = {}) { + const where = ['w.body IS NOT NULL', 'w.acidity IS NOT NULL']; + const params: unknown[] = []; + const add = (sql: string, value: unknown) => { + params.push(value); + where.push(sql.replace('?', `$${params.length}`)); + }; + + if (filter.type) add('w.type = ?', filter.type); + if (filter.country) add('c.name = ?', filter.country); + if (filter.grape) { + add( + `EXISTS ( + SELECT 1 FROM wine_grapes wg + JOIN grapes g ON g.id = wg.grape_id + WHERE wg.wine_id = w.id AND g.name = ? + )`, + filter.grape, + ); + } + // @> bruker GIN-indeksen wines_vintages_idx + if (filter.year != null) add('w.vintages @> ARRAY[?]::int[]', filter.year); + if (filter.minRating != null) add('w.avg_rating >= ?', filter.minRating); + if (filter.minRatingCount != null) { + add('w.rating_count >= ?', filter.minRatingCount); + } + + return { sql: where.join(' AND '), params }; +} + const typeDefs = `#graphql type Wine { @@ -65,37 +228,147 @@ const typeDefs = `#graphql type FoodCategory { name: String! wineCount: Int! + # Rettene i kategorien, verdier fra Harmonize, f.eks. ["Beef", "Lamb"] + foods: [String!]! + } + + # En vin på Find wine by food, med rettene i den valgte kategorien som + # vinen passer til ifølge Harmonize + type FoodWine { + id: ID! + name: String! + type: String! + abv: Float + body: String + acidity: String + winery: String! + region: String! + country: String! + avgRating: Float + ratingCount: Int! + matchedFoods: [String!]! + } + + type FoodWinePage { + items: [FoodWine!]! + totalCount: Int! + } + + # Sortering i vinkatalogen + enum WineSort { + NAME_ASC + NAME_DESC + RATING_DESC + RATING_ASC + ABV_DESC + ABV_ASC + } + + # Filtre for vinkatalogen. Alle er valgfrie; det som mangler, filtreres ikke på. + input WineFilter { + type: String + # Landkode, f.eks. "FR" + country: String + region: ID + grape: String + body: String + acidity: String + # Bare viner med minst så mange vurderinger + minRatingCount: Int + } + + type Country { + code: String! + name: String! + } + + type Region { + id: ID! + name: String! + } + + # Verdiene til nedtrekksmenyene i filterpanelet i vinkatalogen. + # Fylde og syre har faste verdier og ligger i klienten. + type CatalogOptions { + types: [String!]! + countries: [Country!]! + grapes: [String!]! + } + + # Filtre for Explore by taste. Alle er valgfrie; det som mangler, filtreres ikke på. + input TasteFilter { + type: String + grape: String + country: String + year: Int + minRating: Float + minRatingCount: Int + } + + # Verdiene til nedtrekksmenyene i filterpanelet på Explore by taste + type TasteOptions { + types: [String!]! + countries: [String!]! + grapes: [String!]! + years: [Int!]! } type Query { health: String! wines( + search: String + filter: WineFilter + sort: WineSort = NAME_ASC limit: Int = 20 offset: Int = 0 ): WinePage! + catalogOptions: CatalogOptions! + + # Regionene i ett land, til regionfilteret + regions(country: String!): [Region!]! + wine(id: ID!): WineDetails foodCategories: [FoodCategory!]! + # Viner som passer til en matkategori. food snevrer inn til én rett i + # kategorien. De som passer til flest retter i kategorien kommer først, + # deretter de flest har vurdert. winesByFoodCategory( category: String! + food: String limit: Int = 20 offset: Int = 0 - ): WinePage! + ): FoodWinePage! + + # Viner til smakskartet. Bare viner med både fylde og syre kommer med. + tasteMap(filter: TasteFilter, limit: Int = 100): WinePage! + + tasteOptions: TasteOptions! } `; const resolvers = { Query: { - health: () => "OK", - + health: () => 'OK', + wines: async ( _: unknown, - args: { limit?: number; offset?: number } + args: { + search?: string; + filter?: WineFilter | null; + sort?: WineSort | null; + limit?: number; + offset?: number; + }, ) => { const { limit, offset } = readPaging(args); + const search = args.search?.trim() || null; + const { sql, params } = catalogWhere(search, args.filter ?? {}); + // GraphQL godtar bare verdiene i enum WineSort, så oppslaget finner alltid noe + const orderBy = WINE_ORDER[args.sort ?? 'NAME_ASC']; const winesResult = await pool.query( ` @@ -103,27 +376,34 @@ const resolvers = { w.id, w.name, w.type, - w.abv, + w.abv::float AS abv, w.body, w.acidity, wi.name AS winery, r.name AS region, c.name AS country, - w.avg_rating AS "avgRating", + w.avg_rating::float AS "avgRating", w.rating_count AS "ratingCount" FROM wines w JOIN wineries wi ON w.winery_id = wi.id JOIN regions r ON w.region_id = r.id JOIN countries c ON w.country_code = c.code - ORDER BY w.name, w.id - LIMIT $1 - OFFSET $2 + WHERE ${sql} + ORDER BY ${orderBy} + LIMIT $${params.length + 1} + OFFSET $${params.length + 2} `, - [limit, offset] + [...params, limit, offset], ); const countResult = await pool.query( - `SELECT COUNT(*) FROM wines` + ` + SELECT COUNT(*) + FROM wines w + JOIN wineries wi ON w.winery_id = wi.id + WHERE ${sql} + `, + params, ); return { @@ -132,18 +412,44 @@ const resolvers = { }; }, - wine: async ( - _: unknown, - args: { id: string } -) => { - const wineId = Number(args.id); + catalogOptions: async () => { + // Alle druer som minst én vin har, ikke bare de vanligste som på + // Explore by taste, så filteret dekker hele datasettet. + const [types, countries, grapes] = await Promise.all([ + pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`), + pool.query(`SELECT code, name FROM countries ORDER BY name`), + pool.query(` + SELECT g.name + FROM grapes g + WHERE EXISTS (SELECT 1 FROM wine_grapes wg WHERE wg.grape_id = g.id) + ORDER BY g.name + `), + ]); - if (!Number.isInteger(wineId)) { - return null; - } + return { + types: types.rows.map((r) => r.type), + countries: countries.rows, + grapes: grapes.rows.map((r) => r.name), + }; + }, - const result = await pool.query( - ` + regions: async (_: unknown, args: { country: string }) => { + const result = await pool.query( + `SELECT id, name FROM regions WHERE country_code = $1 ORDER BY name`, + [args.country], + ); + return result.rows; + }, + + wine: async (_: unknown, args: { id: string }) => { + const wineId = Number(args.id); + + if (!Number.isInteger(wineId)) { + return null; + } + + const result = await pool.query( + ` SELECT w.id, w.name, @@ -186,68 +492,87 @@ const resolvers = { JOIN countries c ON w.country_code = c.code WHERE w.id = $1 `, - [wineId] - ); + [wineId], + ); - return result.rows[0] ?? null; - }, + return result.rows[0] ?? null; + }, foodCategories: async () => { const result = await pool.query( ` - SELECT f.category AS name, count(DISTINCT wf.wine_id)::int AS "wineCount" + SELECT + f.category AS name, + count(DISTINCT wf.wine_id)::int AS "wineCount", + array_agg(DISTINCT f.name ORDER BY f.name) AS foods FROM foods f JOIN wine_foods wf ON wf.food_id = f.id WHERE f.category IS NOT NULL GROUP BY f.category ORDER BY f.category - ` + `, ); return result.rows; }, winesByFoodCategory: async ( _: unknown, - args: { category: string; limit?: number; offset?: number } + args: { + category: string; + food?: string | null; + limit?: number; + offset?: number; + }, ) => { const { limit, offset } = readPaging(args); - const inCategory = ` - EXISTS ( - SELECT 1 - FROM wine_foods wf - JOIN foods f ON f.id = wf.food_id - WHERE wf.wine_id = w.id AND f.category = $1 - )`; + + // Én rad per vin, med rettene i kategorien vinen passer til. + // Med food må vinen passe til akkurat den retten, men listen viser + // fortsatt alle rettene i kategorien den passer til. + const matches = ` + SELECT wf.wine_id, array_agg(f.name ORDER BY f.name) AS foods + FROM wine_foods wf + JOIN foods f ON f.id = wf.food_id + WHERE f.category = $1 + GROUP BY wf.wine_id + HAVING $2::text IS NULL OR bool_or(f.name = $2::text)`; + const params = [args.category, args.food || null]; const winesResult = await pool.query( ` + WITH matches AS (${matches}) SELECT w.id, w.name, w.type, - w.abv, + w.abv::float AS abv, w.body, w.acidity, wi.name AS winery, r.name AS region, c.name AS country, - w.avg_rating AS "avgRating", - w.rating_count AS "ratingCount" - FROM wines w + w.avg_rating::float AS "avgRating", + w.rating_count AS "ratingCount", + m.foods AS "matchedFoods" + FROM matches m + JOIN wines w ON w.id = m.wine_id JOIN wineries wi ON w.winery_id = wi.id JOIN regions r ON w.region_id = r.id JOIN countries c ON w.country_code = c.code - WHERE ${inCategory} - ORDER BY w.name, w.id - LIMIT $2 - OFFSET $3 + ORDER BY + w.rating_count >= $3 DESC, + cardinality(m.foods) DESC, + w.rating_count DESC, + w.id + LIMIT $4 + OFFSET $5 `, - [args.category, limit, offset] + [...params, FOOD_POPULAR_MIN, limit, offset], ); const countResult = await pool.query( - `SELECT count(*)::int AS count FROM wines w WHERE ${inCategory}`, - [args.category] + `WITH matches AS (${matches}) SELECT count(*)::int AS count FROM matches`, + params, ); return { @@ -255,6 +580,107 @@ const resolvers = { totalCount: countResult.rows[0].count, }; }, + + tasteMap: async ( + _: unknown, + args: { filter?: TasteFilter | null; limit?: number }, + ) => { + const limit = args.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > TASTE_MAP_MAX) { + throw new GraphQLError(`limit must be between 1 and ${TASTE_MAP_MAX}`, { + extensions: { code: 'BAD_USER_INPUT' }, + }); + } + + const { sql, params } = tasteWhere(args.filter ?? {}); + + // Vi viser de vinene flest har vurdert, så kartet fylles med kjente + // viner i stedet for viner med én enkelt vurdering på 5.0. + // De mest vurderte er nesten bare rødvin, så vi tar dem på omgang per + // type: den mest vurderte av hver type først, så den nest mest vurderte + // osv. Uten typefilter blir kartet en jevn miks (100 viner = 20 av hver + // av de fem typene); med typefilter finnes bare én type, og rekkefølgen + // blir som før. 'Dessert' og 'Dessert/Port' regnes som én type, slik + // som i fargeforklaringen på kartet. + const winesResult = await pool.query( + ` + SELECT * + FROM ( + SELECT + w.id, + w.name, + w.type, + w.abv::float AS abv, + w.body, + w.acidity, + wi.name AS winery, + r.name AS region, + c.name AS country, + w.avg_rating::float AS "avgRating", + w.rating_count AS "ratingCount", + ROW_NUMBER() OVER ( + PARTITION BY + CASE WHEN w.type LIKE 'Dessert%' THEN 'Dessert' ELSE w.type END + ORDER BY w.rating_count DESC, w.id + ) AS type_rank + FROM wines w + JOIN wineries wi ON w.winery_id = wi.id + JOIN regions r ON w.region_id = r.id + JOIN countries c ON w.country_code = c.code + WHERE ${sql} + ) ranked + ORDER BY type_rank, "ratingCount" DESC, id + LIMIT $${params.length + 1} + `, + [...params, limit], + ); + + // Totalen telles med de samme filtrene, så "X wines found" viser alle + // vinene som passer, selv om kartet bare tegner de første `limit`. + const countResult = await pool.query( + ` + SELECT count(*)::int AS count + FROM wines w + JOIN countries c ON w.country_code = c.code + WHERE ${sql} + `, + params, + ); + + return { + items: winesResult.rows, + totalCount: countResult.rows[0].count, + }; + }, + + tasteOptions: async () => { + // Fire små spørringer i parallell. Druer begrenses til de 60 vanligste, + // ellers blir nedtrekksmenyen for lang til å være nyttig. + const [types, countries, grapes, years] = await Promise.all([ + pool.query(`SELECT DISTINCT type FROM wines ORDER BY type`), + pool.query(`SELECT name FROM countries ORDER BY name`), + pool.query(` + SELECT g.name + FROM grapes g + JOIN wine_grapes wg ON wg.grape_id = g.id + GROUP BY g.name + ORDER BY count(*) DESC + LIMIT 60 + `), + pool.query(` + SELECT DISTINCT unnest(vintages) AS year + FROM wines + ORDER BY year DESC + `), + ]); + + return { + types: types.rows.map((r) => r.type), + countries: countries.rows.map((r) => r.name), + grapes: grapes.rows.map((r) => r.name).sort(), + years: years.rows.map((r) => r.year), + }; + }, }, }; @@ -267,4 +693,4 @@ const { url } = await startStandaloneServer(server, { listen: { port: Number(process.env.PORT ?? 3001) }, }); -console.log(`GraphQL server running at ${url}`); \ No newline at end of file +console.log(`GraphQL server running at ${url}`); diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 0000000..38175d0 --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1,3 @@ +# Prettier also skips everything in .gitignore (dist, node_modules ...). +# Generated by npm, so it should not be reformatted. +package-lock.json diff --git a/client/README.md b/client/README.md index 2d31cb6..d8cb914 100644 --- a/client/README.md +++ b/client/README.md @@ -40,16 +40,15 @@ export default defineConfig([ // other options... }, }, -]) - +]); ``` You can also install [eslint-plugin-react-x](https://npmx.dev/package/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://npmx.dev/package/eslint-plugin-react-dom) for React-specific lint rules: ```js // eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; export default defineConfig([ globalIgnores(['dist']), @@ -70,6 +69,5 @@ export default defineConfig([ // other options... }, }, -]) - +]); ``` diff --git a/client/eslint.config.js b/client/eslint.config.js index ef614d2..e3927d1 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -1,9 +1,14 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig, globalIgnores } from 'eslint/config' +// ESLint finds bugs and risky patterns in the client code, including +// wrong use of React hooks. Formatting is left to Prettier: +// eslint-config-prettier (last in the list) turns off the ESLint rules +// that would disagree with it. +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import { defineConfig, globalIgnores } from 'eslint/config'; export default defineConfig([ globalIgnores(['dist']), @@ -19,4 +24,5 @@ export default defineConfig([ globals: globals.browser, }, }, -]) + eslintConfigPrettier, +]); diff --git a/client/index.html b/client/index.html index cb93dc2..f7693cd 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + diff --git a/client/package-lock.json b/client/package-lock.json index cb9fa52..62b26de 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -22,9 +22,11 @@ "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0" @@ -1562,6 +1564,22 @@ } } }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", @@ -2493,6 +2511,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/client/package.json b/client/package.json index a82364f..40d957e 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "@apollo/client": "^4.3.2", @@ -24,9 +26,11 @@ "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "eslint": "^10.10.0", + "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.6", "globals": "^17.12.0", + "prettier": "^3.9.10", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", "vite": "^8.3.0" diff --git a/client/src/App.tsx b/client/src/App.tsx index ec50821..be3fa02 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,17 +1,55 @@ +import { Link, Route, Routes, useParams } from 'react-router'; +import SiteHeader from './components/SiteHeader'; +import ExploreByTaste from './components/ExploreByTaste'; import WineCatalog from './components/WineCatalog'; +import FindWineByFood from './components/FindWineByFood'; + +/** + * NB: Temporary placeholder. The detail page is not built yet. + * "View wine details" on Explore by taste links here, so the link + * already works. Replace this with the real detail page component. + */ +function WineDetailsPlaceholder() { + const { id } = useParams(); + return ( +
+

Wine details

+

The detail page for wine {id} is not built yet.

+ ← Back to Explore by taste +
+ ); +} /** * Root component for Wine not? * - *
marks the page's main content, so screen reader and keyboard users - * can jump straight to it. The page has one

, and WineList uses

, - * so the heading hierarchy is correct. + * SiteHeader is shown on every page. Routes picks the page from the URL: + * - / Explore by taste (front page) + * - /food Find wine by food + * - /wines the wine catalog + * - /wines/:id detail page (placeholder for now) + * + * Each page has its own
and one

, so the heading hierarchy is + * correct on every page. */ export default function App() { return ( -
-

Wine not?

- -
+ <> + + + } /> + +

All wines

+ +

+ } + /> + } /> + } /> + + ); } diff --git a/client/src/apollo/client.ts b/client/src/apollo/client.ts index e0a5bff..296653f 100644 --- a/client/src/apollo/client.ts +++ b/client/src/apollo/client.ts @@ -14,4 +14,4 @@ export const client = new ApolloClient({ uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/', }), cache: new InMemoryCache(), -}); \ No newline at end of file +}); diff --git a/client/src/assets/food/cheese.jpg b/client/src/assets/food/cheese.jpg new file mode 100644 index 0000000..b681eb1 Binary files /dev/null and b/client/src/assets/food/cheese.jpg differ diff --git a/client/src/assets/food/dessert.jpg b/client/src/assets/food/dessert.jpg new file mode 100644 index 0000000..a42c4fe Binary files /dev/null and b/client/src/assets/food/dessert.jpg differ diff --git a/client/src/assets/food/fish.jpg b/client/src/assets/food/fish.jpg new file mode 100644 index 0000000..7ac2ec0 Binary files /dev/null and b/client/src/assets/food/fish.jpg differ diff --git a/client/src/assets/food/meat.jpg b/client/src/assets/food/meat.jpg new file mode 100644 index 0000000..752e0a8 Binary files /dev/null and b/client/src/assets/food/meat.jpg differ diff --git a/client/src/assets/food/pasta.jpg b/client/src/assets/food/pasta.jpg new file mode 100644 index 0000000..4d6e110 Binary files /dev/null and b/client/src/assets/food/pasta.jpg differ diff --git a/client/src/assets/food/poultry.jpg b/client/src/assets/food/poultry.jpg new file mode 100644 index 0000000..2618c04 Binary files /dev/null and b/client/src/assets/food/poultry.jpg differ diff --git a/client/src/assets/food/snacks.jpg b/client/src/assets/food/snacks.jpg new file mode 100644 index 0000000..7d4d89d Binary files /dev/null and b/client/src/assets/food/snacks.jpg differ diff --git a/client/src/assets/food/spicy.jpg b/client/src/assets/food/spicy.jpg new file mode 100644 index 0000000..16551bb Binary files /dev/null and b/client/src/assets/food/spicy.jpg differ diff --git a/client/src/assets/food/vegetarian.jpg b/client/src/assets/food/vegetarian.jpg new file mode 100644 index 0000000..927658f Binary files /dev/null and b/client/src/assets/food/vegetarian.jpg differ diff --git a/client/src/assets/vineyard.jpg b/client/src/assets/vineyard.jpg new file mode 100644 index 0000000..a11b3aa Binary files /dev/null and b/client/src/assets/vineyard.jpg differ diff --git a/client/src/components/ExploreByTaste.css b/client/src/components/ExploreByTaste.css new file mode 100644 index 0000000..07d0935 --- /dev/null +++ b/client/src/components/ExploreByTaste.css @@ -0,0 +1,410 @@ +/* + * Explore by taste (front page). + * Colours come from the variables in index.css. + */ + +/* ---------- Hero with vineyard image ---------- */ + +.taste-hero { + position: relative; + overflow: hidden; + background: var(--color-panel); + border-bottom: 1px solid var(--color-border); +} + +/* The image. center 92% shows the lower part, with the rows of vines. */ +.taste-hero::before { + content: ''; + position: absolute; + inset: 0; + background: url('../assets/vineyard.jpg') center 92% / cover no-repeat; +} + +/* A light veil in the middle, so the text stays readable on the image */ +.taste-hero::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse 60% 85% at 50% 50%, + rgb(250 246 244 / 0.72), + rgb(250 246 244 / 0.15) + ); +} + +.taste-hero-inner { + position: relative; + z-index: 1; + max-width: 78rem; + margin: 0 auto; + padding: 3.75rem 1rem; + display: grid; + gap: 0.5rem; + justify-items: center; + text-align: center; +} + +.taste-hero h1 { + margin: 0; + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2rem, 4vw, 2.75rem); + color: var(--color-text); +} + +.taste-hero p { + margin: 0; + max-width: 40rem; + color: var(--color-text); +} + +/* ---------- Three columns: filters | map | selected wine ---------- */ + +main.taste-layout { + max-width: 78rem; + display: grid; + grid-template-columns: 15rem minmax(0, 1fr) 18rem; + gap: 1.25rem; + align-items: start; + padding: 1.5rem 1rem 3rem; +} + +.taste-panel { + min-width: 0; + padding: 1.1rem; + background: var(--color-panel); + border: 1px solid var(--color-border); + border-radius: 0.75rem; +} + +.taste-panel h2 { + margin: 0; +} + +.taste-note { + margin: 0; + font-size: 0.85rem; + color: var(--color-muted); +} + +/* ---------- Filters ---------- */ + +.taste-filters { + display: grid; + gap: 0.9rem; +} + +.taste-filters-head { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.taste-filters-head h2 { + font-family: var(--font-display); + font-weight: 400; + font-size: 1.35rem; +} + +.taste-link-button { + padding: 0; + border: 0; + background: none; + color: var(--color-accent); + font: inherit; + font-size: 0.85rem; + text-decoration: underline; + cursor: pointer; +} + +.taste-field { + display: grid; + gap: 0.3rem; +} + +.taste-field label { + font-size: 0.85rem; + font-weight: 600; +} + +.taste-field select { + padding: 0.45rem 0.6rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background: var(--color-field); + color: var(--color-text); + font: inherit; +} + +.taste-field input[type='range'] { + width: 100%; + accent-color: var(--color-accent); +} + +.taste-check { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.taste-check input { + width: 1rem; + height: 1rem; + accent-color: var(--color-accent); +} + +/* ---------- Map ---------- */ + +.taste-map-panel { + display: grid; + gap: 0.25rem; +} + +.taste-count { + margin: 0; + font-weight: 600; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + +.taste-map { + position: relative; +} + +.taste-map svg { + display: block; + width: 100%; + height: auto; +} + +.taste-map-axis { + stroke: var(--color-muted); + stroke-width: 1.5; +} + +.taste-map-arrow-head { + fill: var(--color-muted); +} + +.taste-map-label text { + fill: var(--color-text); + font-size: 13px; +} + +.taste-dot { + cursor: pointer; +} + +/* The focus ring is drawn by .taste-dot-ring, not the browser outline */ +.taste-dot:focus { + outline: none; +} + +.taste-dot-fill { + stroke: var(--color-panel); + stroke-width: 2; + transition: r 0.12s; +} + +.taste-dot:hover .taste-dot-fill { + r: 9.5; +} + +.taste-dot-ring { + fill: none; + stroke: transparent; + stroke-width: 2; +} + +.taste-dot.is-selected .taste-dot-ring, +.taste-dot:focus-visible .taste-dot-ring { + stroke: var(--color-text); +} + +.taste-dot-hit { + fill: transparent; +} + +.taste-map-tooltip { + position: absolute; + transform: translate(-50%, calc(-100% - 14px)); + display: grid; + padding: 0.45rem 0.65rem; + background: var(--color-field); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 4px 14px rgb(0 0 0 / 0.12); + font-size: 0.82rem; + white-space: nowrap; + pointer-events: none; +} + +.taste-map-tooltip span { + color: var(--color-muted); +} + +.taste-map-legend { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem 1.1rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + font-size: 0.85rem; +} + +.taste-map-legend li { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.taste-swatch { + width: 0.7rem; + height: 0.7rem; + border-radius: 50%; +} + +/* ---------- Selected wine ---------- */ + +.taste-selected { + display: grid; + gap: 0.9rem; +} + +.taste-selected-top { + display: flex; + align-items: center; + gap: 0.9rem; +} + +.taste-selected h2 { + font-family: var(--font-display); + font-weight: 400; + font-size: 1.6rem; + line-height: 1.15; +} + +.taste-glass-box { + flex: none; + display: grid; + place-items: center; + width: 3.25rem; + height: 4.25rem; + border-radius: 0.5rem; + background: var(--color-sunk); + color: var(--color-muted); +} + +.taste-glass { + width: 28px; + height: 44px; +} + +.taste-winery { + margin: 0.15rem 0 0; + color: var(--color-muted); +} + +.taste-facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.75rem; + margin: 0; + font-size: 0.9rem; +} + +.taste-facts dt { + color: var(--color-muted); +} + +.taste-facts dd { + margin: 0; +} + +.taste-rating { + margin: 0; + font-weight: 600; +} + +.taste-rating span { + color: var(--color-star); +} + +.taste-rating small { + font-weight: 400; + color: var(--color-muted); +} + +.taste-bars { + display: grid; + gap: 0.5rem; +} + +.taste-bar-row { + display: grid; + grid-template-columns: 4.5rem 1fr auto; + align-items: center; + gap: 0.6rem; + font-size: 0.88rem; +} + +.taste-bar { + height: 0.5rem; + overflow: hidden; + border-radius: 999px; + background: var(--color-sunk); +} + +.taste-bar i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--color-accent); +} + +.taste-bar-row b { + min-width: 7.5rem; + text-align: right; + font-size: 0.85rem; + font-weight: 600; +} + +.taste-button { + display: block; + padding: 0.7rem; + border-radius: 0.5rem; + background: var(--color-accent); + color: var(--color-accent-ink); + font-weight: 600; + text-align: center; + text-decoration: none; +} + +/* ---------- Smaller screens ---------- */ + +/* Tablet: selected wine moves below filters and map */ +@media (max-width: 1100px) { + main.taste-layout { + grid-template-columns: 14rem minmax(0, 1fr); + } + + .taste-selected { + grid-column: 1 / -1; + } +} + +/* Phone: one column */ +@media (max-width: 760px) { + main.taste-layout { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (prefers-reduced-motion: reduce) { + .taste-dot-fill { + transition: none; + } +} diff --git a/client/src/components/ExploreByTaste.tsx b/client/src/components/ExploreByTaste.tsx new file mode 100644 index 0000000..b6a7a1d --- /dev/null +++ b/client/src/components/ExploreByTaste.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useQuery } from '@apollo/client/react'; +import TasteFilters from './TasteFilters'; +import TasteMap from './TasteMap'; +import SelectedWine from './SelectedWine'; +import { + TASTE_MAP_QUERY, + TASTE_OPTIONS_QUERY, + type TasteMapData, + type TasteOptionsData, +} from '../graphql/queries'; +import type { TasteFilter } from '../types/wine'; +import { + EMPTY_TASTE_FILTER, + POPULAR_MIN_RATINGS, + TASTE_MAP_LIMIT, +} from '../data/taste'; +import './ExploreByTaste.css'; + +/** + * Turns the filter state into GraphQL variables. + * Empty values become null, which the backend reads as "no filter". + */ +function toVariables(filter: TasteFilter) { + return { + limit: TASTE_MAP_LIMIT, + filter: { + type: filter.type || null, + grape: filter.grape || null, + country: filter.country || null, + year: filter.year ? Number(filter.year) : null, + minRating: filter.minRating || null, + minRatingCount: filter.popularOnly ? POPULAR_MIN_RATINGS : null, + }, + }; +} + +/** + * Explore by taste: the front page. + * + * Wines are shown on a map by body and acidity. The user filters in the left + * panel, picks a wine on the map, sees it in the right panel and can go on to + * the detail page. + * + * Data fetching and state live here; TasteFilters, TasteMap and SelectedWine + * only display what they get as props (same split as WineCatalog/WineList). + * + * Scalability: the map never loads the whole dataset. The backend returns at + * most TASTE_MAP_LIMIT wines (the most-rated ones that match the filters, + * taken in turn from each wine type so the map is not almost all red), + * and totalCount tells how many match in total. + */ +export default function ExploreByTaste() { + const [filter, setFilter] = useState(EMPTY_TASTE_FILTER); + const [selectedId, setSelectedId] = useState(null); + + const options = useQuery(TASTE_OPTIONS_QUERY); + const { data, previousData, loading, error } = useQuery( + TASTE_MAP_QUERY, + { variables: toVariables(filter) }, + ); + + // Keep showing the previous wines while new ones load, so the map + // doesn't flash empty every time a filter changes + const shown = (data ?? previousData)?.tasteMap; + const wines = shown?.items ?? []; + + // The selected wine. If it disappears because of a filter, or nothing is + // selected yet, we show the first (most-rated) wine instead. + const selected = wines.find((w) => w.id === selectedId) ?? wines[0] ?? null; + + return ( + <> +
+
+

Explore by taste

+

+ Find wines that match your taste. See how wines compare on body and + acidity, and filter to find exactly what you like. +

+
+
+ +
+ + +
+

Wine map

+ {/* role="status" announces the new count when filters change */} +

+ {!shown + ? 'Loading wines …' + : shown.totalCount > wines.length + ? `Showing ${wines.length} wines` + : `${shown.totalCount.toLocaleString('en')} ${shown.totalCount === 1 ? 'wine' : 'wines'} found.`} +

+ + {error && ( +

Could not load wines. Please try again later.

+ )} + {shown && wines.length === 0 && ( +

+ No wines match these filters. Try removing some of them. +

+ )} + + +
+ + +
+ + ); +} diff --git a/client/src/components/FindWineByFood.css b/client/src/components/FindWineByFood.css new file mode 100644 index 0000000..31409d5 --- /dev/null +++ b/client/src/components/FindWineByFood.css @@ -0,0 +1,342 @@ +/* + * Find wine by food. + * Colours come from the variables in index.css. + */ + +/* ---------- Hero ---------- */ + +.food-hero { + background: + radial-gradient(circle at 82% 50%, rgb(123 30 43 / 0.12), transparent 45%), + linear-gradient(90deg, var(--color-bg) 35%, var(--color-panel)); + border-bottom: 1px solid var(--color-border); +} + +.food-hero-inner { + max-width: 78rem; + margin: 0 auto; + padding: 3rem 1rem; +} + +.food-eyebrow { + margin: 0 0 0.4rem; + font-size: 0.75rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color-muted); +} + +.food-hero h1 { + margin: 0 0 0.5rem; + font-family: var(--font-display); + font-weight: 400; + font-size: clamp(2rem, 5vw, 3rem); + line-height: 1.1; + color: var(--color-text); +} + +.food-hero p:last-child { + margin: 0; + max-width: 28rem; + font-size: 1.1rem; + color: var(--color-muted); +} + +/* ---------- Page ---------- */ + +main.food-page { + max-width: 78rem; + display: grid; + gap: 2rem; + padding: 1.75rem 1rem 3rem; +} + +.food-page h2 { + margin: 0 0 1rem; + font-family: var(--font-display); + font-weight: 400; + font-size: 1.6rem; +} + +.food-note { + margin: 0; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* ---------- Category cards ---------- */ + +.food-categories { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.food-category { + width: 100%; + height: 100%; + display: grid; + justify-items: center; + gap: 0.15rem; + padding: 0.5rem 0.5rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + cursor: pointer; + transition: + border-color 0.15s, + background 0.15s; +} + +.food-category:hover { + border-color: var(--color-muted); +} + +.food-category[aria-pressed='true'] { + border-color: var(--color-accent); + background: var(--color-panel); + box-shadow: inset 0 0 0 1px var(--color-accent); +} + +/* Photo tile at the top of the card */ +.food-category-image { + width: 100%; + height: 5.25rem; + margin-bottom: 0.35rem; + border-radius: 0.5rem; + object-fit: cover; +} + +/* Same height as the photos, so the names line up */ +.food-category-emoji { + display: grid; + place-items: center; + height: 5.25rem; + margin-bottom: 0.35rem; + font-size: 2.8rem; +} + +.food-category-name { + font-weight: 600; +} + +/* ---------- Dish chips ---------- */ + +.food-dishes { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-top: 1rem; +} + +.food-dishes-label { + margin-right: 0.25rem; + font-size: 0.85rem; + font-weight: 600; +} + +.food-dishes button { + padding: 0.35rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 0.88rem; + cursor: pointer; +} + +.food-dishes button:hover { + border-color: var(--color-muted); +} + +.food-dishes button[aria-pressed='true'] { + border-color: var(--color-accent); + background: var(--color-accent); + color: var(--color-accent-ink); +} + +/* ---------- Results ---------- */ + +.food-results { + display: grid; + gap: 1rem; +} + +.food-results-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: baseline; + gap: 0.25rem 1rem; +} + +.food-results-head h2 { + margin: 0; + scroll-margin-top: 1rem; +} + +.food-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} + +.food-card { + height: 100%; + display: grid; + grid-template-columns: 4.5rem minmax(0, 1fr); + gap: 1rem; + padding: 1.1rem; + border: 1px solid var(--color-border); + border-radius: 0.75rem; + background: var(--color-surface); +} + +.food-bottle { + width: 100%; + height: auto; + max-height: 10rem; + align-self: center; +} + +.food-card-body { + display: grid; + align-content: start; + gap: 0.5rem; +} + +.food-card h3 { + margin: 0; + font-family: var(--font-display); + font-weight: 400; + font-size: 1.25rem; + line-height: 1.2; +} + +.food-card h3 a { + color: var(--color-text); + text-decoration: none; +} + +.food-card h3 a:hover { + color: var(--color-accent); + text-decoration: underline; +} + +.food-winery { + margin: -0.35rem 0 0; + font-size: 0.9rem; + color: var(--color-muted); +} + +/* "Pairs with" chip, in place of the "% match" chip in the sketch */ +.food-match { + justify-self: start; + margin: 0; + padding: 0.2rem 0.65rem; + border-radius: 999px; + background: var(--color-panel); + color: var(--color-accent); + font-size: 0.8rem; + font-weight: 600; +} + +.food-meta { + display: flex; + flex-wrap: wrap; + gap: 0.2rem 0.9rem; + margin: 0; + padding: 0; + list-style: none; + font-size: 0.82rem; + color: var(--color-muted); +} + +.food-meta li { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.food-meta svg { + width: 0.9rem; + height: 0.9rem; + color: var(--color-accent); +} + +.food-star { + color: var(--color-star); +} + +.food-bars { + display: grid; + gap: 0.35rem; + margin-top: 0.25rem; +} + +.food-bar-row { + display: grid; + grid-template-columns: 4.25rem 1fr; + align-items: center; + gap: 0.6rem; + font-size: 0.82rem; +} + +.food-bar { + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: var(--color-sunk); +} + +.food-bar i { + display: block; + height: 100%; + border-radius: 999px; + background: var(--color-accent); +} + +/* ---------- Why this pairing works ---------- */ + +.food-why { + display: flex; + gap: 1.25rem; + align-items: flex-start; + padding: 1.25rem 1.5rem; + border-radius: 0.75rem; + background: var(--color-panel); +} + +.food-why svg { + flex: none; + width: 2.25rem; + height: 2.25rem; + color: var(--color-accent); +} + +.food-why h2 { + margin: 0 0 0.25rem; + font-size: 1.25rem; +} + +.food-why p { + margin: 0; + max-width: 50rem; + color: var(--color-muted); +} + +@media (prefers-reduced-motion: reduce) { + .food-category { + transition: none; + } +} diff --git a/client/src/components/FindWineByFood.tsx b/client/src/components/FindWineByFood.tsx new file mode 100644 index 0000000..53b0d11 --- /dev/null +++ b/client/src/components/FindWineByFood.tsx @@ -0,0 +1,227 @@ +import { useSearchParams } from 'react-router'; +import { useQuery } from '@apollo/client/react'; +import FoodWineCard from './FoodWineCard'; +import Pagination from './Pagination'; +import { + FOOD_CATEGORIES_QUERY, + WINES_BY_FOOD_QUERY, + type FoodCategoriesData, + type WinesByFoodData, +} from '../graphql/queries'; +import { + DEFAULT_FOOD_CATEGORY, + FOOD_CATEGORY_INFO, + FOOD_PAGE_SIZE, + foodCategoryOrder, +} from '../data/food'; +import './FindWineByFood.css'; + +/** + * Find wine by food. + * + * The user picks a food category (e.g. Meat), and can narrow it down to one + * dish (e.g. Beef). We show the wines that pair with it according to the + * Harmonize column in X-Wines, and explain why the pairing works. + * + * On purpose there are no wine filters or sorting here: the user chooses by + * food only. The backend decides the order: well-known wines (at least 50 + * ratings) that pair with the most dishes in the category come first. + * + * The choices live in the URL (?category=Meat&food=Beef&page=2), so the back + * button works and a search can be shared as a link. + */ +export default function FindWineByFood() { + const [params, setParams] = useSearchParams(); + const category = params.get('category') ?? DEFAULT_FOOD_CATEGORY; + const food = params.get('food'); + const page = Math.max(1, Number(params.get('page')) || 1); + + const categoriesQuery = useQuery(FOOD_CATEGORIES_QUERY); + const { data, previousData, loading, error } = useQuery( + WINES_BY_FOOD_QUERY, + { + variables: { + category, + food, + limit: FOOD_PAGE_SIZE, + offset: (page - 1) * FOOD_PAGE_SIZE, + }, + }, + ); + + const categories = [...(categoriesQuery.data?.foodCategories ?? [])].sort( + (a, b) => + foodCategoryOrder(a.name) - foodCategoryOrder(b.name) || + a.name.localeCompare(b.name), + ); + const current = categories.find((c) => c.name === category); + const info = FOOD_CATEGORY_INFO[category]; + const label = info?.label ?? category; + + // Keep showing the previous wines while new ones load, so the list + // doesn't flash empty on every click + const shown = (data ?? previousData)?.winesByFoodCategory; + const wines = shown?.items ?? []; + const totalPages = shown ? Math.ceil(shown.totalCount / FOOD_PAGE_SIZE) : 0; + + /** Changes the URL. A new category or dish starts at page 1. */ + function choose(next: { category?: string; food?: string | null }) { + const p = new URLSearchParams(); + p.set('category', next.category ?? category); + const nextFood = next.category ? null : (next.food ?? null); + if (nextFood) p.set('food', nextFood); + setParams(p); + } + + function goToPage(n: number) { + const p = new URLSearchParams(params); + p.set('page', String(n)); + setParams(p); + document + .getElementById('food-results-heading') + ?.scrollIntoView({ block: 'start' }); + } + + return ( + <> +
+
+

Explore

+

Find wine by food

+

+ Choose what you’re eating and discover wines that make it even + better. +

+
+
+ +
+
+

Choose a food category

+ {categoriesQuery.error && ( +

Could not load the food categories.

+ )} +
    + {categories.map((c) => { + const cInfo = FOOD_CATEGORY_INFO[c.name]; + return ( +
  • + +
  • + ); + })} +
+ + {current && current.foods.length > 1 && ( +
+ + + {current.foods.map((f) => ( + + ))} +
+ )} +
+ +
+
+

Best matches for {food ?? label}

+ {/* role="status" announces the new count when the choice changes */} +

+ {!shown + ? 'Loading wines …' + : `${shown.totalCount.toLocaleString('en')} ${shown.totalCount === 1 ? 'wine' : 'wines'} pair with ${food ?? label.toLowerCase()}`} +

+
+ + {error && ( +

Could not load wines. Please try again later.

+ )} + {shown && wines.length === 0 && ( +

No wines pair with this food yet.

+ )} + +
    + {wines.map((w) => ( +
  • + +
  • + ))} +
+ + +
+ + {info && ( + + )} +
+ + ); +} diff --git a/client/src/components/FoodWineCard.tsx b/client/src/components/FoodWineCard.tsx new file mode 100644 index 0000000..225e1a9 --- /dev/null +++ b/client/src/components/FoodWineCard.tsx @@ -0,0 +1,151 @@ +import { Link } from 'react-router'; +import type { FoodWine } from '../types/wine'; +import { ACIDITY_LEVELS, BODY_LEVELS, wineTypeColor } from '../data/taste'; + +/** + * A wine bottle in the colour of the wine type. Decorative only. + * X-Wines has no pictures of the wines, so we draw one. + */ +function Bottle({ type }: { type: string }) { + return ( + + ); +} + +/** One row with label and bar, e.g. "Body ████░". The exact value is read by screen readers and shown on hover. */ +function Bar(props: { label: string; share: number; value: string }) { + return ( +
+ {props.label} +
+ +
+
+ ); +} + +/** + * A card on Find wine by food: the wine, where it is from, which dishes in + * the chosen category it pairs with, and its taste. + * + * Display only, like WineList: it gets the wine as a prop. + */ +export default function FoodWineCard({ wine }: { wine: FoodWine }) { + return ( +
+ + +
+

+ {/* + NB: The detail page (/wines/:id) is not built yet. Until then this + link opens a placeholder page, see App.tsx. + */} + {wine.name} +

+

{wine.winery}

+ +

+ Pairs with + {wine.matchedFoods.join(', ')} +

+ +
    +
  • + + {wine.region}, {wine.country} +
  • +
  • + + {wine.type} wine +
  • +
  • + + {wine.avgRating != null + ? `${wine.avgRating.toFixed(1)} (${wine.ratingCount.toLocaleString('en')} ratings)` + : 'No ratings yet'} +
  • +
+ +
+ {wine.body && ( + + )} + {wine.acidity && ( + + )} + {wine.abv != null && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/Pagination.tsx b/client/src/components/Pagination.tsx index cfe367e..d24d7ad 100644 --- a/client/src/components/Pagination.tsx +++ b/client/src/components/Pagination.tsx @@ -32,17 +32,17 @@ export default function Pagination({ if (totalPages <= 1) return null; return ( -