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/.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/.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/CLAUDE.md b/CLAUDE.md index a0b639f..61da9d3 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,23 @@ 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 +- **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`) -No API keys required. Both are rate-limited; be mindful of request frequency. +## 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 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 31e3987..e678c15 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,43 @@ 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 +``` + +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) +- 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 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 (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 @@ -25,6 +53,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 @@ -33,3 +62,20 @@ npm run dev | `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/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", 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 2d4b1d5..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 MAX_ADDRESSES = 5; +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()], })