Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion .specify/memory/constitution.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions .specify/specs/031-visited-list/plan.md
Original file line number Diff line number Diff line change
@@ -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 |
154 changes: 154 additions & 0 deletions .specify/specs/031-visited-list/spec.md
Original file line number Diff line number Diff line change
@@ -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 |
15 changes: 15 additions & 0 deletions backend/migrations/074_add_user_visits.sql
Original file line number Diff line number Diff line change
@@ -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);
12 changes: 12 additions & 0 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function createAuthRouter(pool) {
isAdmin: true,
role: 'admin',
favorites: [],
visited: [],
preferences: {}
});
}
Expand All @@ -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,
Expand All @@ -112,6 +123,7 @@ export function createAuthRouter(pool) {
isAdmin: is_admin,
role: role || 'viewer',
favorites,
visited,
preferences: preferences || {}
});
} else {
Expand Down
Loading
Loading