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
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);
+ });
+});