diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9c73d9..7951316 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: - run: npm ci + - run: npm test + - run: | cp src/lib/config.example.js src/lib/config.js npm run build diff --git a/.hooks/pre-commit b/.hooks/pre-commit index e58ef06..1257f5b 100755 --- a/.hooks/pre-commit +++ b/.hooks/pre-commit @@ -2,6 +2,13 @@ echo "Running pre-commit checks..." +npm test --silent +if [ $? -ne 0 ]; then + echo "" + echo "Tests failed. Fix errors before committing." + exit 1 +fi + # Ensure config.js exists for build (use example if missing) if [ ! -f src/lib/config.js ]; then cp src/lib/config.example.js src/lib/config.js diff --git a/CLAUDE.md b/CLAUDE.md index 61da9d3..fe0f806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,8 @@ src/ ModeToggle.svelte — restaurant / bar toggle VenueList.svelte — ranked venue results with per-friend distance breakdown MapView.svelte — Leaflet map with markers and walking route polylines + utils.js — pure business logic (haversine, fairness, formatting) + utils.test.js — unit tests for utils.js ``` ## Commands @@ -52,11 +54,19 @@ This app calls these APIs from the browser (no backend): - Before every commit or push, check that `README.md` and `CLAUDE.md` are up-to-date with any features, config changes, or structural changes introduced. Update them if needed before committing. - Commit messages must follow conventional commit format: `type(scope): description`. Enforced by the `commit-msg` hook. +- When adding or modifying business logic in `utils.js`, add or update unit tests in `utils.test.js`. Tests run automatically via pre-commit hook and CI. - To release: push a tag `v*` (e.g. `git tag v1.0.0 && git push origin v1.0.0`). The workflow bumps `package.json`, generates release notes from conventional commits, creates a GitHub release, and deploys to Pages. +## Testing + +- **Framework:** Vitest +- **Test file:** `src/lib/utils.test.js` +- **Run:** `npm test` (single run) or `npm run test:watch` (watch mode) +- Pure business logic lives in `src/lib/utils.js` (no Svelte dependencies). `stores.svelte.js` imports from `utils.js` and wraps with reactive state. +- Tests run in pre-commit hook and CI. A failing test blocks commits and PR merges. + ## Notes -- No test framework is set up yet. - No backend — everything runs client-side. - State management uses Svelte 5 runes (`$state`) in `stores.svelte.js`, exported as shared reactive objects. - Saved groups are persisted in `localStorage` (no database). diff --git a/README.md b/README.md index e678c15..f6272b1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Rendez-vous banner +

+ # Rendez-vous A web app that helps friends find a fair meeting spot. Add everyone's address, pick restaurant or bar, and get a list of nearby venues ranked by fairness — so no one has to travel more than the others. diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..c4222ca Binary files /dev/null and b/assets/banner.png differ diff --git a/package-lock.json b/package-lock.json index 066e868..427d0c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "rendez-vous-tmp", + "name": "rendez-vous", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "rendez-vous-tmp", + "name": "rendez-vous", "version": "0.0.0", "dependencies": { "leaflet": "^1.9.4" @@ -15,7 +15,8 @@ "@tailwindcss/vite": "^4.2.2", "svelte": "^5.53.12", "tailwindcss": "^4.2.2", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.2" } }, "node_modules/@emnapi/core": { @@ -409,6 +410,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", @@ -734,6 +742,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -762,6 +788,119 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -785,6 +924,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -795,6 +944,16 @@ "node": ">= 0.4" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -805,6 +964,13 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -846,6 +1012,13 @@ "node": ">=10.13.0" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -864,6 +1037,26 @@ "@typescript-eslint/types": "^8.2.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1250,6 +1443,13 @@ ], "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1333,6 +1533,13 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1343,6 +1550,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.55.0", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", @@ -1392,6 +1613,23 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1409,6 +1647,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1515,6 +1763,105 @@ } } }, + "node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index eea55d8..32842e2 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", "prepare": "git config core.hooksPath .hooks" }, "devDependencies": { @@ -14,7 +16,8 @@ "@tailwindcss/vite": "^4.2.2", "svelte": "^5.53.12", "tailwindcss": "^4.2.2", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^4.1.2" }, "dependencies": { "leaflet": "^1.9.4" diff --git a/src/lib/stores.svelte.js b/src/lib/stores.svelte.js index 829b23e..3efff3d 100644 --- a/src/lib/stores.svelte.js +++ b/src/lib/stores.svelte.js @@ -1,6 +1,16 @@ import { ORS_API_KEY, MAX_DISTANCE, MAX_ADDRESSES } from './config.js'; +import { + haversineDistance, + fairnessScore, + formatDistanceValue, + formatMaxDistanceValue, + formatDuration as _formatDuration, + computeCentroid, + checkTooFarApart, +} from './utils.js'; export { MAX_DISTANCE, MAX_ADDRESSES }; +export { formatDuration } from './utils.js'; export const apiKey = $state({ value: ORS_API_KEY || sessionStorage.getItem('ors-api-key') || '' }); @@ -50,64 +60,26 @@ function recalcCentroid() { venues.list = []; return; } - const n = friends.list.length; - centroid.lat = friends.list.reduce((s, f) => s + f.lat, 0) / n; - centroid.lng = friends.list.reduce((s, f) => s + f.lng, 0) / n; + const c = computeCentroid(friends.list); + centroid.lat = c.lat; + centroid.lng = c.lng; - tooFarApart.value = false; - for (let i = 0; i < n; i++) { - for (let j = i + 1; j < n; j++) { - const d = haversineDistance(friends.list[i].lat, friends.list[i].lng, friends.list[j].lat, friends.list[j].lng); - if (d > MAX_DISTANCE * 1000) { - tooFarApart.value = true; - venues.list = []; - return; - } - } + tooFarApart.value = checkTooFarApart(friends.list, MAX_DISTANCE); + if (tooFarApart.value) { + venues.list = []; } } -function haversineDistance(lat1, lng1, lat2, lng2) { - const R = 6371000; - const toRad = d => d * Math.PI / 180; - const dLat = toRad(lat2 - lat1); - const dLng = toRad(lng2 - lng1); - const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2; - return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); -} - export function distanceToFriend(venueLat, venueLng, friendLat, friendLng) { return haversineDistance(venueLat, venueLng, friendLat, friendLng); } export function formatDistance(meters) { - if (unit.value === 'mi') { - const feet = meters * 3.28084; - if (feet < 2640) return `${Math.round(feet)}ft`; - return `${(meters / 1609.344).toFixed(1)}mi`; - } - if (meters < 1000) return `${Math.round(meters)}m`; - return `${(meters / 1000).toFixed(1)}km`; + return formatDistanceValue(meters, unit.value); } export function formatMaxDistance() { - if (unit.value === 'mi') return `${(MAX_DISTANCE / 1.609344).toFixed(1)}mi`; - return `${MAX_DISTANCE}km`; -} - -export function formatDuration(seconds) { - const mins = Math.round(seconds / 60); - if (mins < 60) return `${mins}min`; - const h = Math.floor(mins / 60); - const m = mins % 60; - return m > 0 ? `${h}h${m}min` : `${h}h`; -} - -function fairnessScore(distances) { - if (distances.length < 2) return 0; - const avg = distances.reduce((a, b) => a + b, 0) / distances.length; - const variance = distances.reduce((s, d) => s + (d - avg) ** 2, 0) / distances.length; - return Math.sqrt(variance); + return formatMaxDistanceValue(MAX_DISTANCE, unit.value); } async function fetchWalkingDurations(venueLocs) { diff --git a/src/lib/utils.js b/src/lib/utils.js new file mode 100644 index 0000000..0abb128 --- /dev/null +++ b/src/lib/utils.js @@ -0,0 +1,57 @@ +export function haversineDistance(lat1, lng1, lat2, lng2) { + const R = 6371000; + const toRad = d => d * Math.PI / 180; + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2)**2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); +} + +export function fairnessScore(distances) { + if (distances.length < 2) return 0; + const avg = distances.reduce((a, b) => a + b, 0) / distances.length; + const variance = distances.reduce((s, d) => s + (d - avg) ** 2, 0) / distances.length; + return Math.sqrt(variance); +} + +export function formatDistanceValue(meters, unitType) { + if (unitType === 'mi') { + const feet = meters * 3.28084; + if (feet < 2640) return `${Math.round(feet)}ft`; + return `${(meters / 1609.344).toFixed(1)}mi`; + } + if (meters < 1000) return `${Math.round(meters)}m`; + return `${(meters / 1000).toFixed(1)}km`; +} + +export function formatMaxDistanceValue(maxDistanceKm, unitType) { + if (unitType === 'mi') return `${(maxDistanceKm / 1.609344).toFixed(1)}mi`; + return `${maxDistanceKm}km`; +} + +export function formatDuration(seconds) { + const mins = Math.round(seconds / 60); + if (mins < 60) return `${mins}min`; + const h = Math.floor(mins / 60); + const m = mins % 60; + return m > 0 ? `${h}h${m}min` : `${h}h`; +} + +export function computeCentroid(friends) { + if (friends.length === 0) return null; + const n = friends.length; + return { + lat: friends.reduce((s, f) => s + f.lat, 0) / n, + lng: friends.reduce((s, f) => s + f.lng, 0) / n, + }; +} + +export function checkTooFarApart(friends, maxDistanceKm) { + for (let i = 0; i < friends.length; i++) { + for (let j = i + 1; j < friends.length; j++) { + const d = haversineDistance(friends[i].lat, friends[i].lng, friends[j].lat, friends[j].lng); + if (d > maxDistanceKm * 1000) return true; + } + } + return false; +} diff --git a/src/lib/utils.test.js b/src/lib/utils.test.js new file mode 100644 index 0000000..aa614b7 --- /dev/null +++ b/src/lib/utils.test.js @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { + haversineDistance, + fairnessScore, + formatDistanceValue, + formatMaxDistanceValue, + formatDuration, + computeCentroid, + checkTooFarApart, +} from './utils.js'; + +describe('haversineDistance', () => { + it('returns 0 for same point', () => { + expect(haversineDistance(48.8566, 2.3522, 48.8566, 2.3522)).toBe(0); + }); + + it('computes Paris to Lyon (~392km)', () => { + const d = haversineDistance(48.8566, 2.3522, 45.7640, 4.8357); + expect(d).toBeGreaterThan(390000); + expect(d).toBeLessThan(395000); + }); + + it('computes short distance (~1km)', () => { + // ~1km north of Eiffel Tower + const d = haversineDistance(48.8584, 2.2945, 48.8674, 2.2945); + expect(d).toBeGreaterThan(900); + expect(d).toBeLessThan(1100); + }); +}); + +describe('fairnessScore', () => { + it('returns 0 for less than 2 distances', () => { + expect(fairnessScore([500])).toBe(0); + expect(fairnessScore([])).toBe(0); + }); + + it('returns 0 for equal distances', () => { + expect(fairnessScore([1000, 1000, 1000])).toBe(0); + }); + + it('returns higher score for unequal distances', () => { + const equal = fairnessScore([1000, 1000]); + const unequal = fairnessScore([500, 1500]); + expect(unequal).toBeGreaterThan(equal); + }); + + it('computes standard deviation correctly', () => { + // [200, 400] → mean=300, variance=10000, std=100 + expect(fairnessScore([200, 400])).toBe(100); + }); +}); + +describe('formatDistanceValue', () => { + it('formats meters in km mode', () => { + expect(formatDistanceValue(500, 'km')).toBe('500m'); + expect(formatDistanceValue(1500, 'km')).toBe('1.5km'); + expect(formatDistanceValue(50, 'km')).toBe('50m'); + }); + + it('formats meters in miles mode', () => { + expect(formatDistanceValue(100, 'mi')).toBe('328ft'); + expect(formatDistanceValue(5000, 'mi')).toBe('3.1mi'); + }); + + it('uses feet below half mile', () => { + // 2640 feet = 0.5 miles = ~804.7m + const result = formatDistanceValue(800, 'mi'); + expect(result).toMatch(/ft$/); + }); + + it('uses miles above half mile', () => { + const result = formatDistanceValue(1000, 'mi'); + expect(result).toMatch(/mi$/); + }); +}); + +describe('formatMaxDistanceValue', () => { + it('formats in km', () => { + expect(formatMaxDistanceValue(10, 'km')).toBe('10km'); + }); + + it('formats in miles', () => { + expect(formatMaxDistanceValue(10, 'mi')).toBe('6.2mi'); + }); +}); + +describe('formatDuration', () => { + it('formats seconds to minutes', () => { + expect(formatDuration(300)).toBe('5min'); + expect(formatDuration(90)).toBe('2min'); + }); + + it('formats to hours and minutes', () => { + expect(formatDuration(3900)).toBe('1h5min'); + expect(formatDuration(7200)).toBe('2h'); + }); + + it('handles zero', () => { + expect(formatDuration(0)).toBe('0min'); + }); +}); + +describe('computeCentroid', () => { + it('returns null for empty list', () => { + expect(computeCentroid([])).toBeNull(); + }); + + it('returns the point for single friend', () => { + const result = computeCentroid([{ lat: 48.8566, lng: 2.3522 }]); + expect(result.lat).toBeCloseTo(48.8566); + expect(result.lng).toBeCloseTo(2.3522); + }); + + it('computes midpoint for two friends', () => { + const result = computeCentroid([ + { lat: 48.0, lng: 2.0 }, + { lat: 50.0, lng: 4.0 }, + ]); + expect(result.lat).toBeCloseTo(49.0); + expect(result.lng).toBeCloseTo(3.0); + }); +}); + +describe('checkTooFarApart', () => { + it('returns false for empty list', () => { + expect(checkTooFarApart([], 10)).toBe(false); + }); + + it('returns false for single friend', () => { + expect(checkTooFarApart([{ lat: 48.8566, lng: 2.3522 }], 10)).toBe(false); + }); + + it('returns false for close friends', () => { + const friends = [ + { lat: 48.8566, lng: 2.3522 }, + { lat: 48.8606, lng: 2.3376 }, // ~1km apart + ]; + expect(checkTooFarApart(friends, 10)).toBe(false); + }); + + it('returns true for distant friends', () => { + const friends = [ + { lat: 48.8566, lng: 2.3522 }, // Paris + { lat: 45.7640, lng: 4.8357 }, // Lyon + ]; + expect(checkTooFarApart(friends, 10)).toBe(true); + }); + + it('checks all pairs', () => { + const friends = [ + { lat: 48.8566, lng: 2.3522 }, + { lat: 48.8606, lng: 2.3376 }, // close to first + { lat: 45.7640, lng: 4.8357 }, // far from both + ]; + expect(checkTooFarApart(friends, 10)).toBe(true); + }); +});