Skip to content

feat: Visited list + local-first user-data framework + My Valley hub (#429)#451

Merged
fatherlinux merged 2 commits into
masterfrom
feature/429-visited-list
May 31, 2026
Merged

feat: Visited list + local-first user-data framework + My Valley hub (#429)#451
fatherlinux merged 2 commits into
masterfrom
feature/429-visited-list

Conversation

@fatherlinux
Copy link
Copy Markdown
Member

Summary

  • Visited list — mark POIs as visited from the sidebar with progress tracking ("N of M explored"). Mirrors the Favorites/Follow stack: user_visits table (migration 074), routes/visited.js (list/stats/mark/unmark), visited[] in /auth/user, AuthContext toggleVisited, VisitedToggle.
  • Local-first user-data framework — generalized the duplicated "POI-id list in localStorage, synced on login" machinery into createPoiIdListStore() (frontend) + syncPoiIdList() (backend), now shared by Favorites and Visited. Codified as a constitution rule ("User Data: Local-First with Login Sync", 2.0.0→2.1.0) + docs/USER_DATA_FRAMEWORK.md.
  • "My Valley" hub — new personalization page combining Visited (progress bar), Following, and Trips under Settings-style subtabs, reachable signed-in (account menu) and signed-out (Login menu, with a sign-in nudge). TripsManager was extracted from MyTripsModal and embedded in the Trips subtab; the standalone "My Trips" dropdown link was removed.
  • Spec 031-visited-list.

Review

  • Gourmand: clean. Gatehouse: HIGH + actionable MEDIUMs fixed (point-based progress via /api/visited/stats; anon list no longer silently drops ids; CSS renamed). Perf/test findings justified (small indexed table; test container enforces auth, sync verified via dev API).

Closes #429

Test plan

  • Full ./run.sh build
  • Backend API: mark/unmark/list/stats, idempotent sync, 400/404 guards
  • Browser (desktop + mobile): My Valley populated, steady frame, embedded trips, no clipping
  • Post-merge GHA build + smoke test
  • Real signed-out → mark visited → Google sign-in → sync (manual)

🤖 Generated with Claude Code

fatherlinux and others added 2 commits May 31, 2026 08:02
…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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the local-first 'Visited' list feature and the 'My Valley' personalization hub, which aggregates a user's visited places, followed locations, and saved trips. It introduces a reusable local-first framework for POI-id lists, a database migration for user_visits, backend routes, and corresponding frontend components like MyValley and VisitedToggle. Feedback suggests passing the full pois list instead of just destinations to MyValley to correctly resolve names for non-point features (like trails) and filtering point POIs on the client side for accurate progress stats. Additionally, it is recommended to deduplicate the incoming POI IDs in the backend sync helper to avoid redundant database queries.

Comment thread frontend/src/App.jsx
Comment on lines +2531 to +2535
<MyValley
open={showMyValley}
onClose={() => setShowMyValley(false)}
destinations={destinations}
/>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Pass the full pois list (which includes linear features and virtual organizations) instead of just destinations (which only contains point POIs). This ensures that anonymous users can correctly resolve names for visited or followed trails/rivers/organizations in the My Valley hub, rather than seeing them as generic 'Saved place' placeholders.

Suggested change
<MyValley
open={showMyValley}
onClose={() => setShowMyValley(false)}
destinations={destinations}
/>
<MyValley
open={showMyValley}
onClose={() => setShowMyValley(false)}
pois={pois}
/>

Comment on lines +15 to +39
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 [stats, setStats] = useState(null);
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]);

// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Rename the destinations prop to pois to accept the full list of POIs. Then, filter point POIs on the client side to compute progress stats. This fixes two major issues for anonymous users:

  1. Visited or followed trails/rivers/organizations will now display their correct names instead of showing up as generic 'Saved place' placeholders.
  2. The progress bar stats (explored count and total) will only count point POIs, preventing the progress stats from dropping or changing unexpectedly when the user signs in.
export default function MyValley({ open, onClose, pois = [] }) {
  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 [stats, setStats] = useState(null);
  const [loading, setLoading] = useState(false);

  // id -> name lookup over all loaded locations, used
  // to render names for anonymous (localStorage-only) visited/followed ids.
  const nameById = useMemo(() => {
    const map = new Map();
    for (const p of pois) map.set(p.id, p.name);
    return map;
  }, [pois]);

  // Filter point POIs for progress stats to match the server-side counters
  const pointPoiIds = useMemo(() => {
    return new Set(pois.filter(p => p.poi_roles?.includes('point')).map(p => p.id));
  }, [pois]);

  // 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 point POIs as the denominator.
  const total = isAuthenticated && stats ? stats.total : pointPoiIds.size;
  const exploredCount = isAuthenticated
    ? (stats ? stats.visited : visitedList.length)
    : visited.filter(id => pointPoiIds.has(id)).length;
  const percent = total > 0 ? Math.min(100, Math.round((exploredCount / total) * 100)) : 0;

Comment on lines +30 to +33
const poiIds = ids
.map(Number)
.filter(n => Number.isInteger(n) && n > 0)
.slice(0, 500);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Deduplicate the incoming ids array before slicing and querying the database. This prevents redundant elements from being sent to the database and avoids unnecessary conflict resolution overhead in the INSERT ... ON CONFLICT DO NOTHING query.

  const poiIds = [...new Set(ids.map(Number).filter(n => Number.isInteger(n) && n > 0))].slice(0, 500);

@fatherlinux fatherlinux merged commit b31f21a into master May 31, 2026
4 checks passed
@fatherlinux fatherlinux deleted the feature/429-visited-list branch May 31, 2026 12:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Visited List — Mark POIs as Visited with Progress Tracking

1 participant