From e01d3ecad0f3826e7157797709577a2f65091287 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 31 May 2026 08:02:41 -0400 Subject: [PATCH 1/2] feat: visited list + local-first user-data framework + My Valley hub (#429) Mark POIs as visited with progress tracking ("23 of 487 explored"), surfaced alongside Following and Trips in a new "My Valley" hub. - DB: migration 074 user_visits (mirrors user_poi_favorites) - API: routes/visited.js (list/stats/mark/unmark), visited[] in /auth/user - Framework: generalize the duplicated "POI-id list in localStorage, synced on login" machinery into createPoiIdListStore() (frontend) and syncPoiIdList() (backend); reuse for favorites + visited. Codified as a constitution rule (User Data: Local-First with Login Sync, 2.0.0->2.1.0) + docs/USER_DATA_FRAMEWORK.md - AuthContext: visited state + isVisited/toggleVisited (anon + sync) - UI: VisitedToggle in the POI sidebar; MyValley hub (progress + Visited / Following / Trips subtabs, Settings-style); TripsManager extracted from MyTripsModal and embedded in the Trips subtab; "My Trips" dropdown link removed; Following's news feed dropped (lives in notifications) - Spec 031-visited-list Closes #429 Co-Authored-By: Claude Opus 4.8 (1M context) --- .specify/memory/constitution.md | 24 +- .specify/specs/031-visited-list/plan.md | 80 +++++ .specify/specs/031-visited-list/spec.md | 154 ++++++++ backend/migrations/074_add_user_visits.sql | 15 + backend/routes/auth.js | 12 + backend/routes/userSettings.js | 58 ++- backend/routes/visited.js | 106 ++++++ backend/server.js | 2 + backend/tests/visited.integration.test.js | 35 ++ docs/USER_DATA_FRAMEWORK.md | 65 ++++ frontend/src/App.css | 6 + frontend/src/App.jsx | 19 +- frontend/src/components/MyTripsModal.jsx | 331 +---------------- frontend/src/components/MyValley.css | 160 +++++++++ frontend/src/components/MyValley.jsx | 178 +++++++++ frontend/src/components/TripsManager.jsx | 339 ++++++++++++++++++ frontend/src/components/UserSettings.jsx | 119 +----- frontend/src/components/VisitedToggle.jsx | 42 +++ .../src/components/sidebar/ReadOnlyView.jsx | 2 + frontend/src/contexts/AuthContext.jsx | 44 ++- frontend/src/utils/anonSettings.js | 66 ++-- 21 files changed, 1363 insertions(+), 494 deletions(-) create mode 100644 .specify/specs/031-visited-list/plan.md create mode 100644 .specify/specs/031-visited-list/spec.md create mode 100644 backend/migrations/074_add_user_visits.sql create mode 100644 backend/routes/visited.js create mode 100644 backend/tests/visited.integration.test.js create mode 100644 docs/USER_DATA_FRAMEWORK.md create mode 100644 frontend/src/components/MyValley.css create mode 100644 frontend/src/components/MyValley.jsx create mode 100644 frontend/src/components/TripsManager.jsx create mode 100644 frontend/src/components/VisitedToggle.jsx diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index a9e3559d..9c9e7ac3 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,6 +1,6 @@ # rotv Constitution -> **Version:** 2.0.0 +> **Version:** 2.1.0 > **Ratified:** 2026-03-10 > **Status:** Active > **Inherits:** [crunchtools/constitution](https://github.com/crunchtools/constitution) v1.3.0 @@ -18,6 +18,28 @@ AGPL-3.0-or-later Follow Semantic Versioning 2.0.0. MAJOR/MINOR/PATCH. +## User Data: Local-First with Login Sync + +Every user-specific experience (saved places, visited lists, trips, preferences, +and anything personal added in the future) MUST work for **anonymous visitors** +without requiring sign-in. State is persisted in `localStorage` first, and MUST +**sync to the user's account on first sign-in** so it follows them across devices. + +- **Anonymous path:** persist to `localStorage` via the helpers in + `frontend/src/utils/anonSettings.js`. For "list of POI ids" collections, use the + shared `createPoiIdListStore(key)` factory rather than re-implementing read/write. +- **Sync path:** the freshly-signed-in client flushes accumulated state to + `POST /api/user/settings/sync`, which MUST be **server-wins, idempotent, and + re-runnable** (`ON CONFLICT DO NOTHING`, fill-gaps only — never clobber account + data). POI-id collections use the shared `syncPoiIdList()` helper against a + whitelisted `user_*` table. +- **Hydration:** the signed-in client loads its server state from `/auth/user` + and tracks it in `AuthContext`. + +New user features extend this framework instead of inventing a parallel storage +or sync mechanism. The end-to-end recipe is documented in +`docs/USER_DATA_FRAMEWORK.md`. + ## Base Image `quay.io/crunchtools/ubi10-core:latest` — inherits systemd hardening and troubleshooting tools from the crunchtools image tree. diff --git a/.specify/specs/031-visited-list/plan.md b/.specify/specs/031-visited-list/plan.md new file mode 100644 index 00000000..dc508759 --- /dev/null +++ b/.specify/specs/031-visited-list/plan.md @@ -0,0 +1,80 @@ +# Implementation Plan: Visited List & "My Valley" Hub + +> **Spec ID:** 031-visited-list +> **Status:** In Progress +> **Last Updated:** 2026-05-30 +> **Estimated Effort:** L + +## Summary + +Mirror the existing Favorites/Follow stack to add a Visited list, generalize the +shared "POI-id list in localStorage, synced on login" machinery into a reusable +framework, and surface Visited + Following + Trips in a new My Valley hub. + +--- + +## Data Flow + +1. User taps Visited on a POI → `AuthContext.toggleVisited`. +2. Signed-in → `POST/DELETE /api/visited/:id`; anonymous → `localStorage` (`rotv-visited`). +3. On first sign-in → `syncAnonSettings()` flushes local visited ids to + `POST /api/user/settings/sync` → `syncPoiIdList()` inserts into `user_visits`. +4. Signed-in load → `/auth/user` returns `visited`; AuthContext hydrates it. +5. My Valley reads visited/following/trips and renders progress + lists. + +--- + +## Implementation Steps + +### Phase A — Framework +- [x] `createPoiIdListStore(key)` in `anonSettings.js`; reimplement favorites on it; add visited. +- [x] `syncPoiIdList(pool, userId, ids, field)` + whitelist in `userSettings.js`; wire visited. +- [x] Constitution rule + `docs/USER_DATA_FRAMEWORK.md`. + +### Phase B — Visited +- [x] Migration `074_add_user_visits.sql`. +- [x] `routes/visited.js` (list / stats / mark / unmark) + mount in `server.js`. +- [x] `visited` array in `/auth/user`. +- [x] AuthContext `visited` / `isVisited` / `toggleVisited`. +- [x] `VisitedToggle.jsx` in `ReadOnlyView.jsx` + CSS. + +### Phase C — My Valley +- [x] `MyValley.jsx` (+ CSS); progress bar + Visited/Following/Trips tabs. +- [x] Dropdown entries (account + login) and modal render in `App.jsx`. +- [x] Remove duplicated Favorites tab from `UserSettings.jsx`. + +### Phase D — Tests +- [ ] Backend integration tests (`visited.integration.test.js`). +- [ ] E2E toggle/My Valley test. + +--- + +## Testing Strategy + +### Integration Tests +- [ ] `backend/tests/visited.integration.test.js` — POST/DELETE/GET, `/stats`, + `/sync` visited idempotency, `/auth/user` returns `visited` (mirror + `poiSubscriptions.integration.test.js`, `BYPASS_AUTH`). + +### Manual Testing +1. Signed-out: mark visited → reload → persists; My Valley from Login dropdown. +2. Sign in → local rows sync into `user_visits`; progress matches `/api/visited/stats`. +3. Signed-in: toggle on sidebar, unmark in My Valley, check Following + Trips. + +--- + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Refactor of favorites store regresses Following | Med | Favorites helpers re-exported from factory unchanged; covered by existing tests | +| `reload-app` skips migrations/utils | Med | Full `./run.sh build` before verify | +| Denominator mismatch (trails) | Low | Count `'point'` POIs; documented open question | + +--- + +## Changelog + +| Date | Changes | +|------|---------| +| 2026-05-30 | Initial plan | diff --git a/.specify/specs/031-visited-list/spec.md b/.specify/specs/031-visited-list/spec.md new file mode 100644 index 00000000..d23e9e9b --- /dev/null +++ b/.specify/specs/031-visited-list/spec.md @@ -0,0 +1,154 @@ +# Specification: Visited List & "My Valley" Hub + +> **Spec ID:** 031-visited-list +> **Status:** Draft +> **Version:** 0.1.0 +> **Author:** Scott McCarty +> **Date:** 2026-05-30 + +## Overview + +Users can mark POIs as **visited**, building a personal exploration log with +progress stats ("23 of 371 explored"). The visited list, followed places, and +saved trips are surfaced together in a new **My Valley** personalization hub. +Like all user data in ROTV, this is local-first: it works for anonymous visitors +via `localStorage` and syncs to the account on first sign-in. Foundation for the +#141 badge system and recommendations. + +--- + +## User Stories + +### Visited + +**US-031-1: Mark a place visited** +> As a visitor, I want to mark a POI as visited from its sidebar so that I can +> keep a log of where I've been. + +Acceptance Criteria: +- [ ] A "Mark visited" / "Visited" toggle appears in the POI sidebar button row. +- [ ] Works signed-out (persists to `localStorage`) and signed-in (persists to DB). +- [ ] Toggling is optimistic and reflects immediately. + +**US-031-2: See my exploration progress** +> As a visitor, I want to see how many of the valley's locations I've explored so +> that I get a sense of discovery and completeness. + +Acceptance Criteria: +- [ ] My Valley shows "N of M explored" with a progress bar. +- [ ] M = count of markable point locations; N = distinct visited point locations. + +### My Valley hub + +**US-031-3: View my valley** +> As a visitor, I want one place that shows my Visited, Following, and Trips so I +> can manage my personal valley. + +Acceptance Criteria: +- [ ] Reachable from the account dropdown (signed in) and the Login dropdown (signed out). +- [ ] Signed-out shows local data plus a "sign in to save across devices" nudge. +- [ ] Unmark/unfollow from the hub updates the lists. + +### Sync + +**US-031-4: Keep my list on sign-in** +> As an anonymous visitor who later signs in, I want my visited places saved to my +> account so they follow me across devices. + +Acceptance Criteria: +- [ ] On first sign-in, local visited ids sync into `user_visits` (idempotent). +- [ ] Re-syncing never duplicates rows. + +--- + +## Data Model + +### New Tables + +| Table | Description | +|-------|-------------| +| `user_visits` | One row per (user, visited POI), with `visited_at`. | + +### Schema Changes + +```sql +CREATE TABLE IF NOT EXISTS user_visits ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + poi_id INTEGER NOT NULL REFERENCES pois(id) ON DELETE CASCADE, + visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, poi_id) +); +CREATE INDEX IF NOT EXISTS idx_user_visits_poi ON user_visits (poi_id); +``` + +--- + +## API Endpoints + +### New Endpoints + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/api/visited` | List the user's visited POIs | Yes | +| GET | `/api/visited/stats` | `{ visited, total }` progress counters | Yes | +| POST | `/api/visited/:poiId` | Mark a POI visited | Yes | +| DELETE | `/api/visited/:poiId` | Unmark a POI | Yes | + +`/auth/user` gains a `visited` array; `/api/user/settings/sync` accepts a `visited` array. + +--- + +## UI/UX Requirements + +### New Components + +- `VisitedToggle` — sidebar toggle, mirrors `FavoriteToggle`. +- `MyValley` — personalization hub modal: progress bar + Visited / Following / Trips + subtabs (styled like the Settings subtabs). +- `TripsManager` — the trip management UI extracted from `MyTripsModal`, embedded in + the My Valley Trips subtab and reused by the standalone modal. The standalone + "My Trips" dropdown link is removed; trips live under My Valley. + +### Wireframes + +``` +My Valley +██████░░░░░░░░░ 23 of 371 explored + +🧭 Visited (23) ⭐ Following (8) 🗺️ Trips (3) +• Brandywine Falls Unmark +• Ledges Overlook Unmark +``` + +--- + +## Non-Functional Requirements + +**NFR-031-1: Local-first framework** +- Reuses `createPoiIdListStore()` (frontend) and `syncPoiIdList()` (backend) per the + User Data Framework (`docs/USER_DATA_FRAMEWORK.md`). No parallel storage/sync. + +**NFR-031-2: Idempotent** +- Migration and sync re-run safely (`ON CONFLICT DO NOTHING`). + +--- + +## Dependencies + +- Depends on: Google auth (existing), spec `018-anon-user-settings`, `019-poi-subscriptions`. +- Blocks: #141 Phase 3 badge system and recommendations. + +--- + +## Open Questions + +1. Denominator counts `'point'` POIs only; should `'linear'` trails count too? +2. Visited-marker styling on the map (deferred to a follow-up). + +--- + +## Changelog + +| Version | Date | Changes | +|---------|------|---------| +| 0.1.0 | 2026-05-30 | Initial draft | diff --git a/backend/migrations/074_add_user_visits.sql b/backend/migrations/074_add_user_visits.sql new file mode 100644 index 00000000..c1fc03de --- /dev/null +++ b/backend/migrations/074_add_user_visits.sql @@ -0,0 +1,15 @@ +-- 074_add_user_visits.sql +-- Visited list (spec 031-visited-list, issue #429). Logged-in users mark POIs +-- as visited here, building a personal exploration log ("23 of 371 explored"). +-- Anonymous visitors accumulate visited POIs in localStorage and sync into this +-- table on first sign-in via /api/user/settings/sync. Re-runs on every container +-- start, so all statements are idempotent. No backfill — this is a new feature. + +CREATE TABLE IF NOT EXISTS user_visits ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + poi_id INTEGER NOT NULL REFERENCES pois(id) ON DELETE CASCADE, + visited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, poi_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_visits_poi ON user_visits (poi_id); diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 61088584..4c0aca6c 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -88,6 +88,7 @@ export function createAuthRouter(pool) { isAdmin: true, role: 'admin', favorites: [], + visited: [], preferences: {} }); } @@ -104,6 +105,16 @@ export function createAuthRouter(pool) { } catch (err) { console.error('Failed to load favorites for /auth/user:', err); } + let visited = []; + try { + const visitedResult = await pool.query( + `SELECT poi_id FROM user_visits WHERE user_id = $1 ORDER BY visited_at DESC`, + [id] + ); + visited = visitedResult.rows.map(r => r.poi_id); + } catch (err) { + console.error('Failed to load visited for /auth/user:', err); + } res.json({ id, email, @@ -112,6 +123,7 @@ export function createAuthRouter(pool) { isAdmin: is_admin, role: role || 'viewer', favorites, + visited, preferences: preferences || {} }); } else { diff --git a/backend/routes/userSettings.js b/backend/routes/userSettings.js index 86ee609c..9756a6f3 100644 --- a/backend/routes/userSettings.js +++ b/backend/routes/userSettings.js @@ -6,6 +6,42 @@ import { addSubscriber } from '../services/buttondownClient.js'; const MAX_SYNC_TRIPS = 50; +/** + * Whitelist of user POI-id-list tables that anonymous localStorage collections + * sync into. Keys are the sync payload field names; values are the table names. + * The table name is interpolated into SQL, so only these fixed values are ever + * used — never request input. Part of the local-first user-data framework + * (see docs/USER_DATA_FRAMEWORK.md). + */ +const POI_ID_LIST_TABLES = { + favorites: 'user_poi_favorites', + visited: 'user_visits' +}; + +/** + * Sync an anonymous "array of POI ids" collection into its user_* join table. + * Server-wins and idempotent: only inserts ids for POIs that still exist and + * are not deleted, and ON CONFLICT DO NOTHING means re-syncs never duplicate. + * Returns the number of rows inserted. Reused by every POI-id-list user feature. + */ +async function syncPoiIdList(pool, userId, ids, field) { + const table = POI_ID_LIST_TABLES[field]; + if (!table || !Array.isArray(ids) || ids.length === 0) return 0; + const poiIds = ids + .map(Number) + .filter(n => Number.isInteger(n) && n > 0) + .slice(0, 500); + if (poiIds.length === 0) return 0; + const inserted = await pool.query( + `INSERT INTO ${table} (user_id, poi_id) + SELECT $1, p FROM UNNEST($2::int[]) AS p + WHERE EXISTS (SELECT 1 FROM pois WHERE id = p AND deleted IS NOT TRUE) + ON CONFLICT DO NOTHING`, + [userId, poiIds] + ); + return inserted.rowCount; +} + /** * Router for /api/user/settings/sync. * @@ -21,8 +57,8 @@ export function createUserSettingsRouter(pool) { const router = express.Router(); router.post('/sync', isAuthenticated, async (req, res) => { - const { timezone, newsletter, trips, favorites } = req.body || {}; - const synced = { timezone: false, newsletter: false, trips: 0, favorites: 0 }; + const { timezone, newsletter, trips, favorites, visited } = req.body || {}; + const synced = { timezone: false, newsletter: false, trips: 0, favorites: 0, visited: 0 }; try { if (typeof timezone === 'string' && timezone.trim()) { @@ -50,22 +86,8 @@ export function createUserSettingsRouter(pool) { } } - if (Array.isArray(favorites) && favorites.length > 0) { - const poiIds = favorites - .map(Number) - .filter(n => Number.isInteger(n) && n > 0) - .slice(0, 500); - if (poiIds.length > 0) { - const favInsert = await pool.query( - `INSERT INTO user_poi_favorites (user_id, poi_id) - SELECT $1, p FROM UNNEST($2::int[]) AS p - WHERE EXISTS (SELECT 1 FROM pois WHERE id = p AND deleted IS NOT TRUE) - ON CONFLICT DO NOTHING`, - [req.user.id, poiIds] - ); - synced.favorites = favInsert.rowCount; - } - } + synced.favorites = await syncPoiIdList(pool, req.user.id, favorites, 'favorites'); + synced.visited = await syncPoiIdList(pool, req.user.id, visited, 'visited'); if (Array.isArray(trips) && trips.length > 0) { const client = await pool.connect(); diff --git a/backend/routes/visited.js b/backend/routes/visited.js new file mode 100644 index 00000000..7e4a6c7e --- /dev/null +++ b/backend/routes/visited.js @@ -0,0 +1,106 @@ +import express from 'express'; +import rateLimit from 'express-rate-limit'; +import { isAuthenticated } from '../middleware/auth.js'; + +const visitedWriteLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 120, + message: { error: 'Too many visited changes. Please try again later.' }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => (req.user && req.user.id ? `user:${req.user.id}` : req.ip) +}); + +function parsePoiId(value) { + const n = Number(value); + return Number.isInteger(n) && n > 0 ? n : null; +} + +export function createVisitedRouter(pool) { + const router = express.Router(); + + router.get('/', isAuthenticated, async (req, res) => { + try { + const visited = await pool.query( + `SELECT p.id, p.name, p.poi_roles, p.brief_description, p.has_primary_image, + v.visited_at + FROM user_visits v + JOIN pois p ON p.id = v.poi_id + WHERE v.user_id = $1 AND p.deleted IS NOT TRUE + ORDER BY v.visited_at DESC`, + [req.user.id] + ); + res.json(visited.rows); + } catch (err) { + console.error('GET /api/visited failed:', err); + res.status(500).json({ error: 'Failed to load visited list' }); + } + }); + + // Progress stats: how many distinct locations the user has explored out of the + // total markable locations (point POIs — the same set rendered as map markers + // by /api/destinations). Powers the "23 of 371 explored" counter. + router.get('/stats', isAuthenticated, async (req, res) => { + try { + const stats = await pool.query( + `SELECT + (SELECT COUNT(*) FROM pois + WHERE 'point' = ANY(poi_roles) AND deleted IS NOT TRUE) AS total, + (SELECT COUNT(*) FROM user_visits v + JOIN pois p ON p.id = v.poi_id + WHERE v.user_id = $1 AND p.deleted IS NOT TRUE + AND 'point' = ANY(p.poi_roles)) AS visited`, + [req.user.id] + ); + const counts = stats.rows[0] || {}; + res.json({ visited: Number(counts.visited) || 0, total: Number(counts.total) || 0 }); + } catch (err) { + console.error('GET /api/visited/stats failed:', err); + res.status(500).json({ error: 'Failed to load visited stats' }); + } + }); + + router.post('/:poiId', isAuthenticated, visitedWriteLimiter, async (req, res) => { + const poiId = parsePoiId(req.params.poiId); + if (!poiId) { + return res.status(400).json({ error: 'Invalid POI id' }); + } + try { + const poi = await pool.query( + `SELECT id FROM pois WHERE id = $1 AND deleted IS NOT TRUE`, + [poiId] + ); + if (poi.rows.length === 0) { + return res.status(404).json({ error: 'POI not found' }); + } + await pool.query( + `INSERT INTO user_visits (user_id, poi_id) VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [req.user.id, poiId] + ); + res.status(201).json({ poiId, visited: true }); + } catch (err) { + console.error('POST /api/visited/:poiId failed:', err); + res.status(500).json({ error: 'Failed to mark visited' }); + } + }); + + router.delete('/:poiId', isAuthenticated, visitedWriteLimiter, async (req, res) => { + const poiId = parsePoiId(req.params.poiId); + if (!poiId) { + return res.status(400).json({ error: 'Invalid POI id' }); + } + try { + await pool.query( + `DELETE FROM user_visits WHERE user_id = $1 AND poi_id = $2`, + [req.user.id, poiId] + ); + res.json({ poiId, visited: false }); + } catch (err) { + console.error('DELETE /api/visited/:poiId failed:', err); + res.status(500).json({ error: 'Failed to remove visited' }); + } + }); + + return router; +} diff --git a/backend/server.js b/backend/server.js index 910be003..2a1ac61c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -19,6 +19,7 @@ import { createFeedbackRouter } from './routes/feedback.js'; import { createTripsRouter } from './routes/trips.js'; import { createUserSettingsRouter } from './routes/userSettings.js'; import { createFavoritesRouter } from './routes/favorites.js'; +import { createVisitedRouter } from './routes/visited.js'; import { createNotificationsRouter } from './routes/notifications.js'; import { isAuthenticated } from './middleware/auth.js'; import { @@ -197,6 +198,7 @@ app.use('/api/feedback', createFeedbackRouter(pool)); app.use('/api/trips', createTripsRouter(pool)); app.use('/api/user/settings', createUserSettingsRouter(pool)); app.use('/api/favorites', createFavoritesRouter(pool)); +app.use('/api/visited', createVisitedRouter(pool)); app.use('/api/notifications', createNotificationsRouter(pool)); async function importGeoJSONFeatures(client) { diff --git a/backend/tests/visited.integration.test.js b/backend/tests/visited.integration.test.js new file mode 100644 index 00000000..f22a0d64 --- /dev/null +++ b/backend/tests/visited.integration.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import request from 'supertest'; + +const BASE_URL = process.env.TEST_BASE_URL || 'http://localhost:8080'; + +/** + * Integration tests for the Visited list (spec 031-visited-list, issue #429). + * + * Visited is user-scoped (auth required), mirroring favorites. The test + * container enforces auth, so these verify endpoint registration and the auth + * gate; the authenticated mark/unmark and the localStorage→account sync were + * verified manually in dev mode (BYPASS_AUTH=true), per the poiSubscriptions + * convention. + */ +describe('Visited list — endpoint registration', () => { + it('GET /api/visited requires auth', async () => { + const res = await request(BASE_URL).get('/api/visited').expect(401); + expect(res.body).toHaveProperty('error'); + }); + + it('GET /api/visited/stats requires auth', async () => { + const res = await request(BASE_URL).get('/api/visited/stats').expect(401); + expect(res.body).toHaveProperty('error'); + }); + + it('POST /api/visited/:poiId requires auth', async () => { + const res = await request(BASE_URL).post('/api/visited/1').expect(401); + expect(res.body).toHaveProperty('error'); + }); + + it('DELETE /api/visited/:poiId requires auth', async () => { + const res = await request(BASE_URL).delete('/api/visited/1').expect(401); + expect(res.body).toHaveProperty('error'); + }); +}); diff --git a/docs/USER_DATA_FRAMEWORK.md b/docs/USER_DATA_FRAMEWORK.md new file mode 100644 index 00000000..3b500756 --- /dev/null +++ b/docs/USER_DATA_FRAMEWORK.md @@ -0,0 +1,65 @@ +# User Data Framework: Local-First with Login Sync + +Roots of The Valley is **local-first** for user data. Every personal feature works +for anonymous visitors (stored in `localStorage`) and syncs to the user's account +on first sign-in, so it follows them across devices. This is a constitutional rule +(see `.specify/memory/constitution.md` → *User Data: Local-First with Login Sync*). + +This document is the recipe for adding the next user feature. The canonical +references are **Favorites** (`user_poi_favorites`) and **Visited** (`user_visits`), +which are both "array of POI ids" collections sharing the same primitives. + +## The shape + +A user feature flows through five layers: + +``` +localStorage store ──┐ + (anonSettings.js) │ anonymous +AuthContext state ──┤ ↕ on sign-in + (isX / toggleX) │ +/auth/user hydration │ signed-in load +syncAnonSettings() ──┤ flush local → server (once, on first login) +POST /sync insert ──┘ server-wins, idempotent +DB user_* table persistent account state +``` + +## Adding a "list of POI ids" feature (favorites/visited shape) + +1. **Migration** — `backend/migrations/NNN_add_user_.sql`: a + `user_(user_id, poi_id, )` table, `PRIMARY KEY (user_id, poi_id)`, + index on `poi_id`. Idempotent (`CREATE TABLE IF NOT EXISTS`) — migrations re-run + every container start. + +2. **Backend route** — `backend/routes/.js`: `GET /` (list), `POST /:poiId` + (`INSERT … ON CONFLICT DO NOTHING`), `DELETE /:poiId`, behind `isAuthenticated` + with the standard rate limiter. Mount it in `backend/server.js`. + +3. **Auth payload** — in `backend/routes/auth.js` `/user`, add a `` array of + poi_ids so the client hydrates in one round trip. + +4. **Sync** — add the table to `POI_ID_LIST_TABLES` in + `backend/routes/userSettings.js` and call `syncPoiIdList(pool, userId, ids, + '')` in `/sync`. This is the whole server side of sync — it validates ids, + skips deleted POIs, caps the batch, and de-dupes via `ON CONFLICT DO NOTHING`. + +5. **localStorage store** — in `frontend/src/utils/anonSettings.js`, add + `const Store = createPoiIdListStore('rotv-')` and export its + `read/write/add/remove`. Add the collection to the `syncAnonSettings()` payload + and its clear-on-success block. + +6. **AuthContext** — in `frontend/src/contexts/AuthContext.jsx`, add `` + state (init from `read()`), hydrate from `userData.` in + `fetchUser()`, reset on logout, and add `isX(poiId)` / `toggleX(poiId)`. The + toggle is optimistic: signed-in → `POST/DELETE /api//:id` with rollback; + anonymous → the localStorage `add/remove`. + +7. **UI** — a toggle button (mirror `FavoriteToggle.jsx` / `VisitedToggle.jsx`) and + surface the list in **My Valley** (`MyValley.jsx`). + +## Non-POI-id data + +Timezone, newsletter, and trips are also synced through `syncAnonSettings()` / +`/sync` but are not POI-id lists; they each have a bespoke server-wins branch. The +rule (anonymous-first + idempotent login sync) is the same; only the storage helper +differs. diff --git a/frontend/src/App.css b/frontend/src/App.css index 3ecad9b8..e32a36bc 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -15019,6 +15019,12 @@ button.feedback-link-inline { .favorite-toggle-btn.favorited:hover { background: #b07f12; } +.visited-toggle-btn.visited { + background: #2e7d32; +} +.visited-toggle-btn.visited:hover { + background: #256628; +} .notification-bell { position: relative; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c919bad3..8669d8a2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,7 @@ import { useAuth } from './hooks/useAuth'; import { useTrip } from './hooks/useTrip'; import TripBuilder from './components/TripBuilder'; import MyTripsModal from './components/MyTripsModal'; +import MyValley from './components/MyValley'; import useSeasonalTheme from './hooks/useSeasonalTheme'; import useBoatPosition from './hooks/useBoatPosition'; import Map from './components/Map'; @@ -253,6 +254,7 @@ function AppContent() { const [showLoginDropdown, setShowLoginDropdown] = useState(false); const [showUserDropdown, setShowUserDropdown] = useState(false); const [showMyTrips, setShowMyTrips] = useState(false); + const [showMyValley, setShowMyValley] = useState(false); const [tourVariant, setTourVariant] = useState('default'); const { loadFromSlug: loadTripFromSlug, @@ -1954,10 +1956,10 @@ function AppContent() { {isAdmin && Admin} -
- {error &&
{error}
} - - {view === 'mine' && ( - <> -
- - {isAuthenticated && ( - - )} - {isAdmin && ( - - )} -
- {!isAuthenticated && mine.length > 0 && ( -

- These trips are saved to this browser. Sign in to keep them on your account and share them. -

- )} - {loading ? ( -
Loading…
- ) : mine.length === 0 ? ( -
No saved trips yet. Plan one on the map, then tap Save.
- ) : ( -
    - {mine.map(trip => { - const status = shareStatusLabel(trip); - return ( -
  • -
    - - {trip.name}{status ? ` · ${status}` : ''} - - - {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} - {trip.updated_at ? ` · edited ${formatDate(trip.updated_at)}` : ''} - -
    -
    - - {isAuthenticated && ( - - )} - {isAuthenticated && (trip.is_featured || trip.is_public) && ( - - )} - -
    -
  • - ); - })} -
- )} - - )} - - {view === 'discover' && ( - <> - {loading ? ( -
Loading…
- ) : discover.length === 0 ? ( -
No shared trips yet.
- ) : ( -
    - {discover.map(trip => ( -
  • -
    - - {trip.name} - {trip.is_featured ? ' · ⭐ Featured' : (trip.owner_name ? ` · by ${trip.owner_name}` : '')} - - - {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} - {trip.description ? ` · ${trip.description}` : ''} - -
    -
    - - -
    -
  • - ))} -
- )} -
- -
- - )} - - {view === 'pending' && isAdmin && ( - <> - {loading ? ( -
Loading…
- ) : pending.length === 0 ? ( -
No trips awaiting review.
- ) : ( -
    - {pending.map(trip => ( -
  • -
    - {trip.name} - - {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} · by {trip.owner_name || trip.owner_email || 'unknown'} · submitted {formatDate(trip.updated_at)} - -
    -
    - - - -
    -
  • - ))} -
- )} -
- -
- - )} +
diff --git a/frontend/src/components/MyValley.css b/frontend/src/components/MyValley.css new file mode 100644 index 00000000..d8e63388 --- /dev/null +++ b/frontend/src/components/MyValley.css @@ -0,0 +1,160 @@ +.my-valley-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + /* Above the fixed top nav (z-index 10000) so the modal is a true top-level + dialog — its header isn't painted over/clipped, and the backdrop dims the + nav while open. */ + z-index: 10001; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.my-valley-modal { + background: white; + border-radius: 8px; + max-width: 640px; + width: 100%; + /* Fixed height (not max-height) so switching subtabs never resizes the modal + and re-centers it — the body scrolls within a stable frame. */ + height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.24); +} + +.my-valley-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + flex-shrink: 0; +} + +.my-valley-header h2 { + margin: 0; + font-size: 1.1rem; +} + +.my-valley-close-btn { + border: none; + background: transparent; + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + padding: 0 0.25rem; +} + +.my-valley-progress { + padding: 0.75rem 1rem 0.25rem; + flex-shrink: 0; +} + +.my-valley-progress-label { + font-size: 0.9rem; + margin-bottom: 0.4rem; +} + +.my-valley-progress-label strong { + font-size: 1.1rem; + color: #2e7d32; +} + +.my-valley-progress-track { + height: 10px; + border-radius: 6px; + background: #e8ede9; + overflow: hidden; +} + +.my-valley-progress-fill { + height: 100%; + background: #2e7d32; + border-radius: 6px; + transition: width 0.3s ease; +} + +.my-valley-nudge { + margin: 0.5rem 1rem 0; + padding: 0.5rem 0.75rem; + background: #f1f8e9; + border-radius: 6px; + font-size: 0.85rem; + color: #33691e; + flex-shrink: 0; +} + +.my-valley-modal .settings-tabs { + padding: 0.5rem 1rem 0; + flex-shrink: 0; +} + +/* In the modal's tight flex column, keep the subtabs fully visible: don't let + the mobile horizontal-scroll rule clip the labels/underline — wrap instead. */ +@media (max-width: 768px) { + .my-valley-modal .settings-tabs { + flex-wrap: wrap; + overflow: visible; + } +} + +.my-valley-body { + padding: 0.75rem 1rem 1rem; + overflow-y: auto; + flex: 1; +} + +.my-valley-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.my-valley-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: #f7f7f7; + border-radius: 4px; +} + +.my-valley-row-name { + font-weight: 600; +} + +.my-valley-remove-btn { + border: 1px solid rgba(0, 0, 0, 0.12); + background: white; + border-radius: 4px; + padding: 0.2rem 0.5rem; + font-size: 0.8rem; + cursor: pointer; +} + +.my-valley-empty { + padding: 0.75rem 0; + color: rgba(0, 0, 0, 0.6); +} + +.my-valley-empty strong { + display: block; + margin-bottom: 0.25rem; +} + +.my-valley-empty p { + margin: 0 0 0.5rem; + font-size: 0.88rem; +} + +.my-valley-hint { + color: rgba(0, 0, 0, 0.55); + font-style: italic; +} diff --git a/frontend/src/components/MyValley.jsx b/frontend/src/components/MyValley.jsx new file mode 100644 index 00000000..b94481f9 --- /dev/null +++ b/frontend/src/components/MyValley.jsx @@ -0,0 +1,178 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAuth } from '../hooks/useAuth'; +import { readTrips as readLocalTrips } from '../utils/anonSettings'; +import TripsManager from './TripsManager'; +import './MyValley.css'; + +/** + * "My Valley" — the personalization hub. Combines the user's exploration + * progress (Visited), followed places (Following), and saved Trips in one + * place. Local-first: works for anonymous visitors from localStorage-backed + * AuthContext state (favorites/visited) and a sign-in nudge, and shows the + * richer server-backed lists once signed in. Foundation for #141's + * personalized home. + */ +export default function MyValley({ open, onClose, destinations = [] }) { + const { isAuthenticated, visited, favorites, toggleVisited, toggleFavorite } = useAuth(); + const [view, setView] = useState('visited'); + const [visitedList, setVisitedList] = useState([]); + const [followingList, setFollowingList] = useState([]); + const [tripCount, setTripCount] = useState(0); + const [loading, setLoading] = useState(false); + + // id -> name lookup over the markable locations the map already loaded, used + // to render names for anonymous (localStorage-only) visited/followed ids. + const nameById = useMemo(() => { + const map = new Map(); + for (const d of destinations) map.set(d.id, d.name); + return map; + }, [destinations]); + + const total = destinations.length; + const exploredCount = isAuthenticated ? visitedList.length : visited.length; + const percent = total > 0 ? Math.min(100, Math.round((exploredCount / total) * 100)) : 0; + + const toRows = useCallback( + (ids) => ids + .map(id => ({ id, name: nameById.get(id) })) + .filter(row => row.name), + [nameById] + ); + + const load = useCallback(async () => { + if (!isAuthenticated) { + setVisitedList(toRows(visited)); + setFollowingList(toRows(favorites)); + setTripCount(readLocalTrips().length); + return; + } + setLoading(true); + try { + const [visRes, favRes, tripsRes] = await Promise.all([ + fetch('/api/visited', { credentials: 'include' }), + fetch('/api/favorites', { credentials: 'include' }), + fetch('/api/trips/mine', { credentials: 'include' }) + ]); + if (visRes.ok) setVisitedList(await visRes.json()); + if (favRes.ok) setFollowingList(await favRes.json()); + if (tripsRes.ok) setTripCount((await tripsRes.json()).length); + } catch { + setVisitedList([]); + } finally { + setLoading(false); + } + }, [isAuthenticated, visited, favorites, toRows]); + + useEffect(() => { + if (!open) return; + setView('visited'); + load(); + }, [open, load]); + + if (!open) return null; + + const handleUnvisit = async (poiId) => { + setVisitedList(prev => prev.filter(p => p.id !== poiId)); + await toggleVisited(poiId); + }; + + const handleUnfollow = async (poiId) => { + setFollowingList(prev => prev.filter(p => p.id !== poiId)); + await toggleFavorite(poiId); + }; + + return ( +
+
e.stopPropagation()}> +
+

My Valley

+ +
+ +
+
+ {exploredCount} of {total} explored +
+
+
+
+
+ + {!isAuthenticated && ( +
+ Sign in with Google to save your valley across devices. +
+ )} + + + +
+ {loading &&

Loading…

} + + {view === 'visited' && !loading && ( + visitedList.length === 0 ? ( +
+ No places visited yet +

Open a place on the map and tap “Mark visited” to start your exploration log.

+
+ ) : ( +
    + {visitedList.map(poi => ( +
  • + {poi.name} + +
  • + ))} +
+ ) + )} + + {view === 'following' && !loading && ( + followingList.length === 0 ? ( +
+ Not following any places yet +

Open a place and tap “Follow” to get news and event updates in your notifications.

+
+ ) : ( +
    + {followingList.map(poi => ( +
  • + {poi.name} + +
  • + ))} +
+ ) + )} + + {view === 'trips' && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/TripsManager.jsx b/frontend/src/components/TripsManager.jsx new file mode 100644 index 00000000..29b4246f --- /dev/null +++ b/frontend/src/components/TripsManager.jsx @@ -0,0 +1,339 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useTrip } from '../hooks/useTrip'; +import { useAuth } from '../hooks/useAuth'; +import { readTrips as readLocalTrips, removeTrip as removeLocalTrip } from '../utils/anonSettings'; +import './MyTripsModal.css'; + +function formatDate(iso) { + if (!iso) return ''; + try { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); + } catch { + return ''; + } +} + +function shareStatusLabel(trip) { + if (trip.is_featured) return '⭐ Featured'; + if (trip.is_public && trip.is_approved) return '🌐 Shared'; + if (trip.is_public && !trip.is_approved) return '⏳ Pending review'; + return null; +} + +/** + * The trip management UI (mine / discover / pending views) without any modal + * chrome. Rendered both inside the standalone MyTripsModal and embedded in the + * My Valley hub's Trips subtab. `onClosed` is called when opening a trip should + * dismiss the surrounding container; `active` (re)loads the user's trips when + * the host becomes visible. + */ +export default function TripsManager({ active = true, onClosed }) { + const { trip: activeTrip, loadTrip, clear } = useTrip(); + const { isAdmin, isAuthenticated } = useAuth(); + const [mine, setMine] = useState([]); + const [discover, setDiscover] = useState([]); + const [pending, setPending] = useState([]); + const [view, setView] = useState('mine'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copiedId, setCopiedId] = useState(null); + + const refreshMine = useCallback(async () => { + if (!isAuthenticated) { + setMine(readLocalTrips().map(t => ({ + ...t, + stop_count: t.stops?.length || 0, + updated_at: t.savedAt + }))); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch('/api/trips/mine', { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to load trips'); + setMine(await res.json()); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [isAuthenticated]); + + const refreshDiscover = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/trips/discover', { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to load trips'); + setDiscover(await res.json()); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + const refreshPending = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/trips/pending', { credentials: 'include' }); + if (!res.ok) throw new Error('Failed to load pending trips'); + setPending(await res.json()); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!active) return; + setView('mine'); + refreshMine(); + }, [active, refreshMine]); + + if (!active) return null; + + const closeAfterOpen = () => { if (onClosed) onClosed(); }; + + const handleOpen = async (slug) => { + if (!isAuthenticated) { + const local = readLocalTrips().find(t => t.slug === slug); + if (local) { + loadTrip(local); + closeAfterOpen(); + } else { + setError('Could not load trip'); + } + return; + } + try { + const res = await fetch(`/api/trips/${encodeURIComponent(slug)}`, { credentials: 'include' }); + if (!res.ok) throw new Error('Could not load trip'); + const data = await res.json(); + loadTrip(data); + closeAfterOpen(); + } catch (err) { + setError(err.message); + } + }; + + const handleDuplicate = async (id) => { + try { + const res = await fetch(`/api/trips/${id}/duplicate`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error('Could not duplicate trip'); + await refreshMine(); + } catch (err) { + setError(err.message); + } + }; + + const handleDelete = async (id) => { + if (!window.confirm('Delete this trip?')) return; + try { + const res = await fetch(`/api/trips/${id}`, { method: 'DELETE', credentials: 'include' }); + if (!res.ok) throw new Error('Could not delete trip'); + if (activeTrip && activeTrip.id === id) clear(); + await refreshMine(); + } catch (err) { + setError(err.message); + } + }; + + const handleDeleteLocal = (slug) => { + if (!window.confirm('Delete this trip?')) return; + removeLocalTrip(slug); + if (activeTrip && activeTrip.slug === slug) clear(); + refreshMine(); + }; + + const handleCopyLink = async (trip) => { + const url = `${window.location.origin}/trip/${trip.slug}`; + let ok = false; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(url); + ok = true; + } else { + const ta = document.createElement('textarea'); + ta.value = url; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { ok = document.execCommand('copy'); } catch { ok = false; } + document.body.removeChild(ta); + } + } catch { + ok = false; + } + if (ok) { + setCopiedId(trip.id); + setTimeout(() => setCopiedId(prev => (prev === trip.id ? null : prev)), 1800); + } else { + window.prompt('Copy this link:', url); + } + }; + + const handleClone = async (id) => { + try { + const res = await fetch(`/api/trips/${id}/duplicate`, { method: 'POST', credentials: 'include' }); + if (!res.ok) throw new Error('Could not add trip to your list'); + setView('mine'); + await refreshMine(); + } catch (err) { + setError(err.message); + } + }; + + const handleModerate = async (id, action) => { + try { + const res = await fetch(`/api/trips/${id}/moderate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ action }) + }); + if (!res.ok) throw new Error(`Could not ${action} trip`); + await refreshPending(); + } catch (err) { + setError(err.message); + } + }; + + return ( + <> + {error &&
{error}
} + + {view === 'mine' && ( + <> +
+ + {isAuthenticated && ( + + )} + {isAdmin && ( + + )} +
+ {!isAuthenticated && mine.length > 0 && ( +

+ These trips are saved to this browser. Sign in to keep them on your account and share them. +

+ )} + {loading ? ( +
Loading…
+ ) : mine.length === 0 ? ( +
No saved trips yet. Plan one on the map, then tap Save.
+ ) : ( +
    + {mine.map(trip => { + const status = shareStatusLabel(trip); + return ( +
  • +
    + + {trip.name}{status ? ` · ${status}` : ''} + + + {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} + {trip.updated_at ? ` · edited ${formatDate(trip.updated_at)}` : ''} + +
    +
    + + {isAuthenticated && ( + + )} + {isAuthenticated && (trip.is_featured || trip.is_public) && ( + + )} + +
    +
  • + ); + })} +
+ )} + + )} + + {view === 'discover' && ( + <> + {loading ? ( +
Loading…
+ ) : discover.length === 0 ? ( +
No shared trips yet.
+ ) : ( +
    + {discover.map(trip => ( +
  • +
    + + {trip.name} + {trip.is_featured ? ' · ⭐ Featured' : (trip.owner_name ? ` · by ${trip.owner_name}` : '')} + + + {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} + {trip.description ? ` · ${trip.description}` : ''} + +
    +
    + + +
    +
  • + ))} +
+ )} +
+ +
+ + )} + + {view === 'pending' && isAdmin && ( + <> + {loading ? ( +
Loading…
+ ) : pending.length === 0 ? ( +
No trips awaiting review.
+ ) : ( +
    + {pending.map(trip => ( +
  • +
    + {trip.name} + + {trip.stop_count} stop{Number(trip.stop_count) === 1 ? '' : 's'} · by {trip.owner_name || trip.owner_email || 'unknown'} · submitted {formatDate(trip.updated_at)} + +
    +
    + + + +
    +
  • + ))} +
+ )} +
+ +
+ + )} + + ); +} diff --git a/frontend/src/components/UserSettings.jsx b/frontend/src/components/UserSettings.jsx index 4d4a3658..f7fc2109 100644 --- a/frontend/src/components/UserSettings.jsx +++ b/frontend/src/components/UserSettings.jsx @@ -1,12 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import GeneralSettings from './GeneralSettings'; import McpSettings from './McpSettings'; -import { useAuth } from '../hooks/useAuth'; import { readEmail, writeEmail, writeSubscribed } from '../utils/anonSettings'; -import { safeHttpUrl } from '../utils/url'; function UserSettings({ user, initialTab }) { - const { toggleFavorite } = useAuth(); const [activeTab, setActiveTab] = useState(initialTab || 'general'); useEffect(() => { @@ -16,36 +13,6 @@ function UserSettings({ user, initialTab }) { const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); - const [favorites, setFavorites] = useState([]); - const [feed, setFeed] = useState({ news: [], events: [] }); - const [favLoading, setFavLoading] = useState(false); - - const loadFavorites = useCallback(async () => { - if (!user) return; - setFavLoading(true); - try { - const [favRes, feedRes] = await Promise.all([ - fetch('/api/favorites', { credentials: 'include' }), - fetch('/api/notifications/feed', { credentials: 'include' }) - ]); - if (favRes.ok) setFavorites(await favRes.json()); - if (feedRes.ok) setFeed(await feedRes.json()); - } catch (err) { - setFavorites([]); - } finally { - setFavLoading(false); - } - }, [user]); - - useEffect(() => { - if (activeTab === 'favorites') loadFavorites(); - }, [activeTab, loadFavorites]); - - const handleUnfavorite = async (poiId) => { - setFavorites(prev => prev.filter(p => p.id !== poiId)); - await toggleFavorite(poiId); - }; - useEffect(() => { if (user?.email) { setEmail(user.email); @@ -100,14 +67,6 @@ function UserSettings({ user, initialTab }) { > General - {user && ( - - )} - - ))} - - )} - - {(feed.events.length > 0 || feed.news.length > 0) && ( - <> -
- {feed.events.length > 0 && ( - <> -

📅 Upcoming events

-
    - {feed.events.map(e => ( -
  • - {safeHttpUrl(e.source_url) ? ( - {e.title} - ) : e.title} - · {e.poi_name} -
  • - ))} -
- - )} - {feed.news.length > 0 && ( - <> -

📰 Recent news

-
    - {feed.news.map(n => ( -
  • - {safeHttpUrl(n.source_url) ? ( - {n.title} - ) : n.title} - · {n.poi_name} -
  • - ))} -
- - )} - - )} -
- )} - {activeTab === 'newsletter' && (

📧 Newsletter

diff --git a/frontend/src/components/VisitedToggle.jsx b/frontend/src/components/VisitedToggle.jsx new file mode 100644 index 00000000..3bb9328d --- /dev/null +++ b/frontend/src/components/VisitedToggle.jsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import { useAuth } from '../hooks/useAuth'; + +export default function VisitedToggle({ poi, className = 'share-badge-btn visited-toggle-btn' }) { + const { isVisited, toggleVisited } = useAuth(); + const [busy, setBusy] = useState(false); + + const poiId = poi && poi.id ? poi.id : null; + if (!poiId) return null; + + const visited = isVisited(poiId); + + const handleClick = async (e) => { + e.stopPropagation(); + if (busy) return; + setBusy(true); + try { + await toggleVisited(poiId); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/frontend/src/components/sidebar/ReadOnlyView.jsx b/frontend/src/components/sidebar/ReadOnlyView.jsx index 9c0f2677..bd9cadd8 100644 --- a/frontend/src/components/sidebar/ReadOnlyView.jsx +++ b/frontend/src/components/sidebar/ReadOnlyView.jsx @@ -2,6 +2,7 @@ import { formatDateTime } from '../NewsEventsShared'; import NavigateButton from '../NavigateButton'; import AddToTripButton from '../AddToTripButton'; import FavoriteToggle from '../FavoriteToggle'; +import VisitedToggle from '../VisitedToggle'; import CellSignal from './CellSignal'; import { getNavigationStops, getOwnerClass, formatCoordinate, WHEELCHAIR_LABELS, FEE_LABELS, humanizeOpeningHours } from './helpers'; @@ -87,6 +88,7 @@ function ReadOnlyView({ destination, isLinearFeature, isAdmin, editMode, onShare +
{destination.status_url && trailStatus && trailStatus.status !== 'unknown' && (trailStatus.conditions || trailStatus.weather_impact || trailStatus.seasonal_closure || trailStatus.last_updated) && ( diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 1760376c..2988462a 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -3,7 +3,10 @@ import { syncAnonSettings, readFavorites, addFavorite as addAnonFavorite, - removeFavorite as removeAnonFavorite + removeFavorite as removeAnonFavorite, + readVisited, + addVisited as addAnonVisited, + removeVisited as removeAnonVisited } from '../utils/anonSettings'; export const AuthContext = createContext(null); @@ -13,6 +16,7 @@ export function AuthProvider({ children }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [favorites, setFavorites] = useState(() => readFavorites()); + const [visited, setVisited] = useState(() => readVisited()); const fetchUser = useCallback(async () => { try { @@ -24,19 +28,23 @@ export function AuthProvider({ children }) { if (userData) { setUser(userData); setFavorites(userData.favorites || []); + setVisited(userData.visited || []); } else { setUser(null); setFavorites(readFavorites()); + setVisited(readVisited()); } } else { setUser(null); setFavorites(readFavorites()); + setVisited(readVisited()); } } catch (err) { console.error('Failed to fetch user:', err); setError(err.message); setUser(null); setFavorites(readFavorites()); + setVisited(readVisited()); } finally { setLoading(false); } @@ -67,6 +75,7 @@ export function AuthProvider({ children }) { if (response.ok) { setUser(null); setFavorites(readFavorites()); + setVisited(readVisited()); } } catch (err) { console.error('Logout failed:', err); @@ -112,6 +121,36 @@ export function AuthProvider({ children }) { return !wasFavorited; }, [favorites, user]); + const isVisited = useCallback( + (poiId) => visited.includes(poiId), + [visited] + ); + + const toggleVisited = useCallback(async (poiId) => { + const wasVisited = visited.includes(poiId); + const next = wasVisited + ? visited.filter(id => id !== poiId) + : [...visited, poiId]; + setVisited(next); + + if (user) { + try { + const res = await fetch(`/api/visited/${poiId}`, { + method: wasVisited ? 'DELETE' : 'POST', + credentials: 'include' + }); + if (!res.ok) throw new Error('Request failed'); + } catch (err) { + setVisited(visited); + } + } else if (wasVisited) { + removeAnonVisited(poiId); + } else { + addAnonVisited(poiId); + } + return !wasVisited; + }, [visited, user]); + const value = { user, loading, @@ -122,6 +161,9 @@ export function AuthProvider({ children }) { favorites, isFavorited, toggleFavorite, + visited, + isVisited, + toggleVisited, logout, loginWithGoogle, loginWithFacebook, diff --git a/frontend/src/utils/anonSettings.js b/frontend/src/utils/anonSettings.js index a3fad50c..7190a5a3 100644 --- a/frontend/src/utils/anonSettings.js +++ b/frontend/src/utils/anonSettings.js @@ -10,6 +10,7 @@ const KEY_NEWSLETTER_EMAIL = 'rotv-newsletter-email'; const KEY_NEWSLETTER_SUBSCRIBED = 'rotv-newsletter-subscribed'; const KEY_SAVED_TRIPS = 'rotv-saved-trips'; const KEY_FAVORITES = 'rotv-favorites'; +const KEY_VISITED = 'rotv-visited'; function safeRead(key) { try { @@ -72,31 +73,44 @@ export function removeTrip(slug) { writeTrips(readTrips().filter(t => t.slug !== slug)); } -export function readFavorites() { - const raw = safeRead(KEY_FAVORITES); - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(n => Number.isInteger(n)) : []; - } catch { - return []; - } -} - -export function writeFavorites(poiIds) { - safeWrite(KEY_FAVORITES, JSON.stringify(poiIds)); +/** + * Factory for an anonymous "array of POI ids" localStorage collection — the + * canonical local-first user-data primitive. Returns read/write/add/remove + * bound to one storage key. Favorites and Visited (and any future POI-id list) + * share this so the next user feature is a one-liner, not a copy-paste. + * See docs/USER_DATA_FRAMEWORK.md. + */ +export function createPoiIdListStore(key) { + const read = () => { + const raw = safeRead(key); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(n => Number.isInteger(n)) : []; + } catch { + return []; + } + }; + const write = (poiIds) => safeWrite(key, JSON.stringify(poiIds)); + const add = (poiId) => { + const ids = read(); + if (!ids.includes(poiId)) write([...ids, poiId]); + }; + const remove = (poiId) => write(read().filter(id => id !== poiId)); + return { read, write, add, remove }; } -export function addFavorite(poiId) { - const favorites = readFavorites(); - if (!favorites.includes(poiId)) { - writeFavorites([...favorites, poiId]); - } -} +const favoritesStore = createPoiIdListStore(KEY_FAVORITES); +export const readFavorites = favoritesStore.read; +export const writeFavorites = favoritesStore.write; +export const addFavorite = favoritesStore.add; +export const removeFavorite = favoritesStore.remove; -export function removeFavorite(poiId) { - writeFavorites(readFavorites().filter(id => id !== poiId)); -} +const visitedStore = createPoiIdListStore(KEY_VISITED); +export const readVisited = visitedStore.read; +export const writeVisited = visitedStore.write; +export const addVisited = visitedStore.add; +export const removeVisited = visitedStore.remove; /** * Flush accumulated anonymous state to the backend on first successful @@ -115,8 +129,10 @@ export async function syncAnonSettings() { const subscribed = safeRead(KEY_NEWSLETTER_SUBSCRIBED) === 'true'; const trips = readTrips(); const favorites = readFavorites(); + const visited = readVisited(); - const hasState = timezone || (email && subscribed) || trips.length > 0 || favorites.length > 0; + const hasState = timezone || (email && subscribed) || trips.length > 0 + || favorites.length > 0 || visited.length > 0; if (!hasState) return { synced: false }; const payload = {}; @@ -124,6 +140,7 @@ export async function syncAnonSettings() { if (email && subscribed) payload.newsletter = { email, subscribed }; if (trips.length > 0) payload.trips = trips; if (favorites.length > 0) payload.favorites = favorites; + if (visited.length > 0) payload.visited = visited; try { const res = await fetch('/api/user/settings/sync', { @@ -144,6 +161,9 @@ export async function syncAnonSettings() { if (favorites.length > 0) { safeRemove(KEY_FAVORITES); } + if (visited.length > 0) { + safeRemove(KEY_VISITED); + } return { synced: true }; } catch { From 9c33fd148dc136ed2b7e0f60d961bb06ee852292 Mon Sep 17 00:00:00 2001 From: Scott McCarty Date: Sun, 31 May 2026 08:05:54 -0400 Subject: [PATCH 2/2] fix: address Gatehouse review for My Valley (#429) - Progress counter uses /api/visited/stats for signed-in users so N and M match the backend's point-based definition (HIGH) - Anonymous visited/following rows fall back to "Saved place" instead of being silently dropped when a name isn't in the loaded destinations (MEDIUM) - Rename MyTripsModal.css -> TripsManager.css to reflect the shared component Justified (not changed): /api/visited/stats count scan (small table + GIN index on poi_roles, infrequent); MyTripsModal retained (still opened by TripBuilder); authed/sync covered manually per the poiSubscriptions test convention (test container enforces auth). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/MyTripsModal.jsx | 2 +- frontend/src/components/MyValley.jsx | 21 ++++++++++++------- .../{MyTripsModal.css => TripsManager.css} | 0 frontend/src/components/TripsManager.jsx | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) rename frontend/src/components/{MyTripsModal.css => TripsManager.css} (100%) diff --git a/frontend/src/components/MyTripsModal.jsx b/frontend/src/components/MyTripsModal.jsx index 1f05499a..4d07fb26 100644 --- a/frontend/src/components/MyTripsModal.jsx +++ b/frontend/src/components/MyTripsModal.jsx @@ -1,6 +1,6 @@ import React from 'react'; import TripsManager from './TripsManager'; -import './MyTripsModal.css'; +import './TripsManager.css'; export default function MyTripsModal({ open, onClose }) { if (!open) return null; diff --git a/frontend/src/components/MyValley.jsx b/frontend/src/components/MyValley.jsx index b94481f9..7f733e23 100644 --- a/frontend/src/components/MyValley.jsx +++ b/frontend/src/components/MyValley.jsx @@ -18,6 +18,7 @@ export default function MyValley({ open, onClose, destinations = [] }) { const [visitedList, setVisitedList] = useState([]); const [followingList, setFollowingList] = useState([]); const [tripCount, setTripCount] = useState(0); + const [stats, setStats] = useState(null); const [loading, setLoading] = useState(false); // id -> name lookup over the markable locations the map already loaded, used @@ -28,19 +29,23 @@ export default function MyValley({ open, onClose, destinations = [] }) { return map; }, [destinations]); - const total = destinations.length; - const exploredCount = isAuthenticated ? visitedList.length : visited.length; + // Signed-in: trust the server's point-based counters (/api/visited/stats) so N + // and M match the spec's "markable point locations". Signed-out: derive from + // localStorage against the loaded destinations (point POIs) as the denominator. + const total = isAuthenticated && stats ? stats.total : destinations.length; + const exploredCount = isAuthenticated + ? (stats ? stats.visited : visitedList.length) + : visited.length; const percent = total > 0 ? Math.min(100, Math.round((exploredCount / total) * 100)) : 0; const toRows = useCallback( - (ids) => ids - .map(id => ({ id, name: nameById.get(id) })) - .filter(row => row.name), + (ids) => ids.map(id => ({ id, name: nameById.get(id) || 'Saved place' })), [nameById] ); const load = useCallback(async () => { if (!isAuthenticated) { + setStats(null); setVisitedList(toRows(visited)); setFollowingList(toRows(favorites)); setTripCount(readLocalTrips().length); @@ -48,14 +53,16 @@ export default function MyValley({ open, onClose, destinations = [] }) { } setLoading(true); try { - const [visRes, favRes, tripsRes] = await Promise.all([ + const [visRes, favRes, tripsRes, statsRes] = await Promise.all([ fetch('/api/visited', { credentials: 'include' }), fetch('/api/favorites', { credentials: 'include' }), - fetch('/api/trips/mine', { credentials: 'include' }) + fetch('/api/trips/mine', { credentials: 'include' }), + fetch('/api/visited/stats', { credentials: 'include' }) ]); if (visRes.ok) setVisitedList(await visRes.json()); if (favRes.ok) setFollowingList(await favRes.json()); if (tripsRes.ok) setTripCount((await tripsRes.json()).length); + if (statsRes.ok) setStats(await statsRes.json()); } catch { setVisitedList([]); } finally { diff --git a/frontend/src/components/MyTripsModal.css b/frontend/src/components/TripsManager.css similarity index 100% rename from frontend/src/components/MyTripsModal.css rename to frontend/src/components/TripsManager.css diff --git a/frontend/src/components/TripsManager.jsx b/frontend/src/components/TripsManager.jsx index 29b4246f..0eb1ec34 100644 --- a/frontend/src/components/TripsManager.jsx +++ b/frontend/src/components/TripsManager.jsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTrip } from '../hooks/useTrip'; import { useAuth } from '../hooks/useAuth'; import { readTrips as readLocalTrips, removeTrip as removeLocalTrip } from '../utils/anonSettings'; -import './MyTripsModal.css'; +import './TripsManager.css'; function formatDate(iso) { if (!iso) return '';