From f89ca1ad53e05825c0f004b665f3a5145186a5a2 Mon Sep 17 00:00:00 2001 From: Benjamin Danies Date: Sat, 28 Mar 2026 14:18:56 +0100 Subject: [PATCH 1/3] docs(readme): update README and CLAUDE.md with current features Update both files to reflect walking mode, route visualization, saved groups, optional friend names, and config setup instructions. Also bump default MAX_ADDRESSES to 10 in config.example.js. --- CLAUDE.md | 36 +++++++++++++++++++++--------------- README.md | 29 ++++++++++++++++++++++++++++- src/lib/config.example.js | 2 +- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a0b639f..c172201 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Rendez-vous -A Svelte 5 web app that helps friends find a fair meeting spot (restaurant or bar) based on everyone's location. Users add friends by address, the app geocodes them, computes the centroid, and queries the Overpass API for nearby venues ranked by fairness (equal distance to all participants). +A Svelte 5 web app that helps friends find a fair meeting spot (restaurant or bar) based on everyone's location. Users add friends by name and address, the app geocodes them, computes the centroid, and queries the Overpass API for nearby venues ranked by fairness (equal distance or walking time). ## Tech Stack @@ -10,21 +10,26 @@ A Svelte 5 web app that helps friends find a fair meeting spot (restaurant or ba - **Map:** Leaflet - **Geocoding:** Nominatim (OpenStreetMap) - **Venue data:** Overpass API +- **Walking directions & time matrix:** OpenRouteService (requires API key) ## Project Structure ``` src/ - App.svelte — root layout (header, sidebar, map) - main.js — mount point - app.css — global styles / Tailwind + App.svelte — root layout (header, sidebar, map) + main.js — mount point + app.css — global styles / Tailwind lib/ - stores.svelte.js — shared state (friends, venues, centroid) and business logic - AddressInput.svelte — geocoded address input - FriendList.svelte — list of added friends - ModeToggle.svelte — restaurant / bar toggle - VenueList.svelte — ranked venue results - MapView.svelte — Leaflet map with markers + config.js — local config with API key and limits (gitignored) + config.example.js — config template (committed) + stores.svelte.js — shared state (friends, venues, centroid, ranking) and business logic + groups.svelte.js — saved groups CRUD with localStorage persistence + AddressInput.svelte — geocoded address input with optional name + FriendList.svelte — list of added friends + GroupManager.svelte — save / load / edit / delete groups + 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 ``` ## Commands @@ -37,15 +42,16 @@ npm run preview # preview production build ## External APIs -This app calls these public APIs from the browser (no backend): +This app calls these APIs from the browser (no backend): -- **Nominatim** — address geocoding -- **Overpass API** — OpenStreetMap venue queries - -No API keys required. Both are rate-limited; be mindful of request frequency. +- **Nominatim** — address geocoding (free, no key) +- **Overpass API** — OpenStreetMap venue queries (free, no key, rate-limited) +- **OpenRouteService** — walking directions and duration matrix (free tier, requires API key in `config.js`) ## 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). +- `config.js` is gitignored — copy `config.example.js` and add your ORS API key. diff --git a/README.md b/README.md index 31e3987..c53ef54 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,41 @@ Built with Svelte 5, Leaflet, and OpenStreetMap data. Runs entirely in the brows ```sh npm install +cp src/lib/config.example.js src/lib/config.js +``` + +Edit `src/lib/config.js` with your OpenRouteService API key (free at https://openrouteservice.org/dev/#/signup). Then: + +```sh npm run dev ``` +## Features + +- Add friends by name (optional) and address +- Two ranking modes: **equidistant** (straight-line distance) or **walking time** (via OpenRouteService) +- Walking route visualization on the map with a different color per friend +- Per-friend distance breakdown when selecting a venue +- Save and load groups of addresses (persisted in localStorage) +- Configurable max distance between friends and max number of addresses + ## How It Works 1. Each person enters their address (geocoded via Nominatim) 2. The app computes the geographic centroid of all participants 3. Nearby restaurants or bars are fetched from the Overpass API -4. Venues are ranked by fairness — lowest variance in distance to all friends comes first +4. Venues are ranked by fairness — lowest variance in distance (or walking time) to all friends +5. Click a venue to see walking routes from each friend's address + +## Configuration + +Edit `src/lib/config.js`: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ORS_API_KEY` | — | OpenRouteService API key (required for walking mode and routes) | +| `MAX_DISTANCE_KM` | `10` | Max allowed distance between any two friends (km) | +| `MAX_ADDRESSES` | `10` | Max number of friends/addresses | ## Tech Stack @@ -25,6 +51,7 @@ npm run dev - **Leaflet** for the interactive map - **Nominatim** for geocoding - **Overpass API** for venue search +- **OpenRouteService** for walking directions and time matrix ## Scripts diff --git a/src/lib/config.example.js b/src/lib/config.example.js index 2d4b1d5..8dd3e6c 100644 --- a/src/lib/config.example.js +++ b/src/lib/config.example.js @@ -3,4 +3,4 @@ export const ORS_API_KEY = 'YOUR_API_KEY_HERE'; export const MAX_DISTANCE_KM = 10; -export const MAX_ADDRESSES = 5; +export const MAX_ADDRESSES = 10; From d39eb74917d14ecdc821dd434f864e12cd2f7f43 Mon Sep 17 00:00:00 2001 From: Benjamin Danies Date: Sat, 28 Mar 2026 14:25:57 +0100 Subject: [PATCH 2/3] chore(ci): add GitHub Actions CI and pre-commit build hook CI runs build check on PRs to main. Pre-commit hook runs build locally before each commit. Hook auto-installed via npm prepare script. --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .hooks/pre-commit | 25 +++++++++++++++++++++++++ package.json | 3 ++- 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100755 .hooks/pre-commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e9c73d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: | + cp src/lib/config.example.js src/lib/config.js + npm run build diff --git a/.hooks/pre-commit b/.hooks/pre-commit new file mode 100755 index 0000000..e58ef06 --- /dev/null +++ b/.hooks/pre-commit @@ -0,0 +1,25 @@ +#!/bin/sh + +echo "Running pre-commit checks..." + +# 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 + CREATED_CONFIG=1 +fi + +npm run build --silent +BUILD_EXIT=$? + +# Clean up temp config +if [ "$CREATED_CONFIG" = "1" ]; then + rm src/lib/config.js +fi + +if [ $BUILD_EXIT -ne 0 ]; then + echo "" + echo "Build failed. Fix errors before committing." + exit 1 +fi + +echo "Pre-commit checks passed." diff --git a/package.json b/package.json index 6a74b73..eea55d8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "prepare": "git config core.hooksPath .hooks" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", From d5c1d54af887e1393fbbc410a36dd0cc5911f7a1 Mon Sep 17 00:00:00 2001 From: Benjamin Danies Date: Sat, 28 Mar 2026 14:55:40 +0100 Subject: [PATCH 3/3] feat(app): add km/mi toggle, user API key input, release workflow and CI - km/miles unit toggle in header - ORS API key input via UI (sessionStorage, no persistence) - Walking mode disabled when no key provided - Rename MAX_DISTANCE_KM to MAX_DISTANCE - GitHub Actions deploy workflow triggered by version tags - Manual release workflow with patch/minor/major bump - commit-msg hook enforcing conventional commits - Vite base path for GitHub Pages --- .github/workflows/deploy.yml | 96 +++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 51 +++++++++++++++++++ .hooks/commit-msg | 26 ++++++++++ CLAUDE.md | 9 +++- README.md | 25 +++++++-- src/App.svelte | 64 +++++++++++++++++++---- src/lib/MapView.svelte | 3 +- src/lib/config.example.js | 9 ++-- src/lib/stores.svelte.js | 33 ++++++++++-- vite.config.js | 1 + 10 files changed, 293 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/release.yml create mode 100755 .hooks/commit-msg diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..6bb201a --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,96 @@ +name: Release & Deploy + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Set version from tag + run: | + VERSION="${GITHUB_REF_NAME#v}" + npm version "$VERSION" --no-git-tag-version + + - name: Generate release notes + id: notes + run: | + PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | sed -n '2p') + if [ -z "$PREV_TAG" ]; then + RANGE="HEAD" + else + RANGE="${PREV_TAG}..HEAD" + fi + + FEATURES=$(git log "$RANGE" --pretty=format:'%s' | grep -E '^feat' | sed 's/^/- /' || true) + FIXES=$(git log "$RANGE" --pretty=format:'%s' | grep -E '^fix' | sed 's/^/- /' || true) + OTHERS=$(git log "$RANGE" --pretty=format:'%s' | grep -vE '^(feat|fix|Merge)' | sed 's/^/- /' || true) + + { + echo "notes<> "$GITHUB_OUTPUT" + + - name: Create GitHub release + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes "${{ steps.notes.outputs.notes }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - run: npm ci + + - name: Build + run: | + cp src/lib/config.example.js src/lib/config.js + npm run build + + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: release + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..922795b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + bump: + description: 'Version bump type' + required: true + type: choice + options: + - patch + - minor + - major + +permissions: + contents: write + +jobs: + tag: + if: github.actor == github.repository_owner + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute next version + id: version + run: | + LATEST=$(git tag --sort=-v:refname | grep '^v' | head -1 || echo "v0.0.0") + MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) + MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) + PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) + + case "${{ inputs.bump }}" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + + echo "tag=v${MAJOR}.${MINOR}.${PATCH}" >> "$GITHUB_OUTPUT" + + - name: Create and push tag + run: | + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Summary + run: echo "Created tag **${{ steps.version.outputs.tag }}** — deploy workflow will run next." >> "$GITHUB_STEP_SUMMARY" diff --git a/.hooks/commit-msg b/.hooks/commit-msg new file mode 100755 index 0000000..df0cbb4 --- /dev/null +++ b/.hooks/commit-msg @@ -0,0 +1,26 @@ +#!/bin/sh + +# Enforce conventional commit format: type(scope): description +# Types: feat, fix, chore, refactor, docs, test, style, perf, ci, build + +msg=$(head -1 "$1") + +# Allow merge commits +if echo "$msg" | grep -qE '^Merge '; then + exit 0 +fi + +if ! echo "$msg" | grep -qE '^(feat|fix|chore|refactor|docs|test|style|perf|ci|build)(\(.+\))?: .+'; then + echo "" + echo "Invalid commit message format:" + echo " $msg" + echo "" + echo "Expected: type(scope): description" + echo "Types: feat, fix, chore, refactor, docs, test, style, perf, ci, build" + echo "Examples:" + echo " feat(map): add walking route visualization" + echo " fix(api): handle Overpass 429 errors" + echo " docs(readme): update feature list" + echo "" + exit 1 +fi diff --git a/CLAUDE.md b/CLAUDE.md index c172201..61da9d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,10 +48,17 @@ This app calls these APIs from the browser (no backend): - **Overpass API** — OpenStreetMap venue queries (free, no key, rate-limited) - **OpenRouteService** — walking directions and duration matrix (free tier, requires API key in `config.js`) +## Workflow Rules + +- 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. +- 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. + ## 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). -- `config.js` is gitignored — copy `config.example.js` and add your ORS API key. +- `config.js` is gitignored — copy `config.example.js` and adjust values as needed. +- ORS API key is optional in config — users can enter their own key in the app UI (settings icon). Key is stored in `sessionStorage` only (cleared on browser close). diff --git a/README.md b/README.md index c53ef54..e678c15 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ npm install cp src/lib/config.example.js src/lib/config.js ``` -Edit `src/lib/config.js` with your OpenRouteService API key (free at https://openrouteservice.org/dev/#/signup). Then: +Then: ```sh npm run dev @@ -21,9 +21,11 @@ npm run dev - Add friends by name (optional) and address - Two ranking modes: **equidistant** (straight-line distance) or **walking time** (via OpenRouteService) +- ORS API key can be entered in the app UI (click the settings icon) — stored in session only, never persisted - Walking route visualization on the map with a different color per friend - Per-friend distance breakdown when selecting a venue - Save and load groups of addresses (persisted in localStorage) +- km / miles toggle - Configurable max distance between friends and max number of addresses ## How It Works @@ -40,8 +42,8 @@ Edit `src/lib/config.js`: | Variable | Default | Description | |----------|---------|-------------| -| `ORS_API_KEY` | — | OpenRouteService API key (required for walking mode and routes) | -| `MAX_DISTANCE_KM` | `10` | Max allowed distance between any two friends (km) | +| `ORS_API_KEY` | `''` | OpenRouteService API key (optional — users can enter their own in the UI) | +| `MAX_DISTANCE` | `10` | Max allowed distance between any two friends (in km) | | `MAX_ADDRESSES` | `10` | Max number of friends/addresses | ## Tech Stack @@ -60,3 +62,20 @@ Edit `src/lib/config.js`: | `npm run dev` | Start dev server | | `npm run build` | Production build | | `npm run preview` | Preview production build | + +## Deployment + +Push a version tag to trigger a release and deploy to GitHub Pages: + +```sh +git tag v1.0.0 +git push origin v1.0.0 +``` + +This automatically: +1. Bumps `package.json` version +2. Generates release notes from conventional commits +3. Creates a GitHub release +4. Deploys to GitHub Pages + +Live at: https://bendns.github.io/rendez-vous/ diff --git a/src/App.svelte b/src/App.svelte index b88bc3c..d75be11 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,10 +5,12 @@ import VenueList from './lib/VenueList.svelte'; import MapView from './lib/MapView.svelte'; import GroupManager from './lib/GroupManager.svelte'; - import { friends, venues, mode, ranking, tooFarApart, MAX_DISTANCE_KM, MAX_ADDRESSES, searchVenues, rerankVenues } from './lib/stores.svelte.js'; + import { friends, venues, mode, ranking, unit, apiKey, setApiKey, tooFarApart, MAX_DISTANCE, MAX_ADDRESSES, searchVenues, rerankVenues, formatMaxDistance } from './lib/stores.svelte.js'; let selectedVenue = $state(null); let sidebarOpen = $state(true); + let showSettings = $state(false); + let keyInput = $state(apiKey.value); function handleSelectVenue(venue) { selectedVenue = venue; @@ -25,14 +27,53 @@

Find the perfect meeting spot

- +
+
+ + +
+ + +
+ + {#if showSettings} +
+ + setApiKey(keyInput.trim())} + placeholder="Paste your OpenRouteService key..." + class="flex-1 bg-white rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-dark placeholder:text-light outline-none focus:border-coral/30 max-w-md" + /> + {#if apiKey.value} + Active + {:else} + Get a free key + {/if} +
+ {/if} +
@@ -64,7 +105,7 @@
⚠️

- Some friends are more than {MAX_DISTANCE_KM}km apart. Add closer addresses to find a meeting spot. + Some friends are more than {formatMaxDistance()} apart. Add closer addresses to find a meeting spot.

{/if} @@ -92,11 +133,14 @@ Distance
diff --git a/src/lib/MapView.svelte b/src/lib/MapView.svelte index ed1e0ae..fe17b4a 100644 --- a/src/lib/MapView.svelte +++ b/src/lib/MapView.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; - import { friends, venues, centroid, mode, fetchRoutes } from './stores.svelte.js'; + import { friends, venues, centroid, mode, apiKey, fetchRoutes } from './stores.svelte.js'; let { selectedVenue = $bindable(null) } = $props(); @@ -154,6 +154,7 @@ function drawRoutes(venue) { if (!map || !venue) return; clearRoutes(); + if (!apiKey.value) return; const version = ++routeVersion; map.flyTo([venue.lat, venue.lon], 15, { duration: 0.8 }); diff --git a/src/lib/config.example.js b/src/lib/config.example.js index 8dd3e6c..d7faa10 100644 --- a/src/lib/config.example.js +++ b/src/lib/config.example.js @@ -1,6 +1,7 @@ -// Copy this file to config.js and fill in your values. -// Get a free API key at https://openrouteservice.org/dev/#/signup +// Copy this file to config.js and adjust values as needed. +// ORS API key is optional here — users can enter their own in the app UI. +// Get a free key at https://openrouteservice.org/dev/#/signup -export const ORS_API_KEY = 'YOUR_API_KEY_HERE'; -export const MAX_DISTANCE_KM = 10; +export const ORS_API_KEY = ''; +export const MAX_DISTANCE = 10; // in km export const MAX_ADDRESSES = 10; diff --git a/src/lib/stores.svelte.js b/src/lib/stores.svelte.js index 3f1ccca..829b23e 100644 --- a/src/lib/stores.svelte.js +++ b/src/lib/stores.svelte.js @@ -1,6 +1,19 @@ -import { ORS_API_KEY, MAX_DISTANCE_KM, MAX_ADDRESSES } from './config.js'; +import { ORS_API_KEY, MAX_DISTANCE, MAX_ADDRESSES } from './config.js'; -export { MAX_DISTANCE_KM, MAX_ADDRESSES }; +export { MAX_DISTANCE, MAX_ADDRESSES }; + +export const apiKey = $state({ value: ORS_API_KEY || sessionStorage.getItem('ors-api-key') || '' }); + +export function setApiKey(key) { + apiKey.value = key; + if (key) { + sessionStorage.setItem('ors-api-key', key); + } else { + sessionStorage.removeItem('ors-api-key'); + } +} + +export const unit = $state({ value: 'km' }); export const friends = $state({ list: [] }); export const venues = $state({ list: [], loading: false }); @@ -45,7 +58,7 @@ function recalcCentroid() { 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_KM * 1000) { + if (d > MAX_DISTANCE * 1000) { tooFarApart.value = true; venues.list = []; return; @@ -68,10 +81,20 @@ export function distanceToFriend(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`; } +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`; @@ -99,7 +122,7 @@ async function fetchWalkingDurations(venueLocs) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': ORS_API_KEY, + 'Authorization': apiKey.value, }, body: JSON.stringify({ locations, sources, destinations, metrics: ['duration'] }), }); @@ -116,7 +139,7 @@ export async function fetchRoutes(venue) { friends.list.map(async (f) => { const res = await fetch( `https://api.openrouteservice.org/v2/directions/foot-walking?start=${f.lng},${f.lat}&end=${venue.lon},${venue.lat}`, - { headers: { 'Authorization': ORS_API_KEY } }, + { headers: { 'Authorization': apiKey.value } }, ); if (!res.ok) throw new Error(`ORS directions error: ${res.status}`); const data = await res.json(); diff --git a/vite.config.js b/vite.config.js index bcf9f72..48c201f 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,5 +3,6 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ + base: '/rendez-vous/', plugins: [svelte(), tailwindcss()], })