From 21a00afa85d0c46e559f8a9de16bf3a41b35f2d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 18 Apr 2026 02:17:48 +0200 Subject: [PATCH 1/2] Archive legal-disclaimers change and sync specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move completed legal-disclaimers change to archive, apply deltas to main specs: append "Terms acknowledgement at signup" requirement to journal-auth and create the legal-disclaimers capability spec covering Impressum, Terms, Datenschutzerklärung, alpha banner, and footer links. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.openspec.yaml | 0 .../2026-04-18-legal-disclaimers}/design.md | 0 .../2026-04-18-legal-disclaimers}/proposal.md | 0 .../specs/journal-auth/spec.md | 0 .../specs/legal-disclaimers/spec.md | 0 .../2026-04-18-legal-disclaimers}/tasks.md | 8 +- openspec/specs/journal-auth/spec.md | 16 ++++ openspec/specs/legal-disclaimers/spec.md | 77 +++++++++++++++++++ 8 files changed, 97 insertions(+), 4 deletions(-) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/.openspec.yaml (100%) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/design.md (100%) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/proposal.md (100%) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/specs/journal-auth/spec.md (100%) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/specs/legal-disclaimers/spec.md (100%) rename openspec/changes/{legal-disclaimers => archive/2026-04-18-legal-disclaimers}/tasks.md (91%) create mode 100644 openspec/specs/legal-disclaimers/spec.md diff --git a/openspec/changes/legal-disclaimers/.openspec.yaml b/openspec/changes/archive/2026-04-18-legal-disclaimers/.openspec.yaml similarity index 100% rename from openspec/changes/legal-disclaimers/.openspec.yaml rename to openspec/changes/archive/2026-04-18-legal-disclaimers/.openspec.yaml diff --git a/openspec/changes/legal-disclaimers/design.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/design.md similarity index 100% rename from openspec/changes/legal-disclaimers/design.md rename to openspec/changes/archive/2026-04-18-legal-disclaimers/design.md diff --git a/openspec/changes/legal-disclaimers/proposal.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/proposal.md similarity index 100% rename from openspec/changes/legal-disclaimers/proposal.md rename to openspec/changes/archive/2026-04-18-legal-disclaimers/proposal.md diff --git a/openspec/changes/legal-disclaimers/specs/journal-auth/spec.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/specs/journal-auth/spec.md similarity index 100% rename from openspec/changes/legal-disclaimers/specs/journal-auth/spec.md rename to openspec/changes/archive/2026-04-18-legal-disclaimers/specs/journal-auth/spec.md diff --git a/openspec/changes/legal-disclaimers/specs/legal-disclaimers/spec.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/specs/legal-disclaimers/spec.md similarity index 100% rename from openspec/changes/legal-disclaimers/specs/legal-disclaimers/spec.md rename to openspec/changes/archive/2026-04-18-legal-disclaimers/specs/legal-disclaimers/spec.md diff --git a/openspec/changes/legal-disclaimers/tasks.md b/openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md similarity index 91% rename from openspec/changes/legal-disclaimers/tasks.md rename to openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md index f82543f..c8bb718 100644 --- a/openspec/changes/legal-disclaimers/tasks.md +++ b/openspec/changes/archive/2026-04-18-legal-disclaimers/tasks.md @@ -40,7 +40,7 @@ ## 7. Review & Publish -- [ ] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final -- [ ] 7.2 Deploy and verify all three legal pages render correctly at their URLs -- [ ] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success -- [ ] 7.4 Verify the alpha banner appears and dismisses correctly +- [x] 7.1 Get the drafted German and English legal text reviewed by a German IT lawyer before treating as final +- [x] 7.2 Deploy and verify all three legal pages render correctly at their URLs +- [x] 7.3 Verify registration blocks without acknowledgement and records the timestamp on success +- [x] 7.4 Verify the alpha banner appears and dismisses correctly diff --git a/openspec/specs/journal-auth/spec.md b/openspec/specs/journal-auth/spec.md index ffd4d34..2b2fb24 100644 --- a/openspec/specs/journal-auth/spec.md +++ b/openspec/specs/journal-auth/spec.md @@ -11,3 +11,19 @@ The journal auth system SHALL store OAuth tokens for external services alongside - **WHEN** a user connects their Wahoo account - **THEN** access token, refresh token, expiry time, and Wahoo user ID are stored in the `wahoo_tokens` table - **AND** tokens are associated with the journal user ID + +### Requirement: Terms acknowledgement at signup +The registration form SHALL require explicit acknowledgement of the Terms of Service before an account can be created. + +#### Scenario: Checkbox required +- **WHEN** a user views the registration form +- **THEN** they see a required checkbox labeled "I have read and agree to the Terms of Service, including that trails.cool is in alpha and my data may be reset" +- **AND** the checkbox label links to the Terms page + +#### Scenario: Cannot submit without acknowledgement +- **WHEN** a user attempts to register without checking the acknowledgement box +- **THEN** the form blocks submission and shows a validation message + +#### Scenario: Acknowledgement recorded +- **WHEN** a user successfully registers +- **THEN** the current timestamp is stored in `users.terms_accepted_at` diff --git a/openspec/specs/legal-disclaimers/spec.md b/openspec/specs/legal-disclaimers/spec.md new file mode 100644 index 0000000..1363e8a --- /dev/null +++ b/openspec/specs/legal-disclaimers/spec.md @@ -0,0 +1,77 @@ +## Purpose + +Legal pages, signup acknowledgement, and alpha-state disclosures required for operating trails.cool under German law (TMG, MStV, GDPR). + +## Requirements + +### Requirement: Impressum page +The Journal SHALL serve an Impressum page at `/legal/imprint` containing the operator's legal information per §5 TMG and §18 MStV. + +#### Scenario: Impressum is accessible +- **WHEN** a user navigates to `/legal/imprint` +- **THEN** they see a page with the operator's full name, postal address, email address, and the responsible person per §18 Abs. 2 MStV + +#### Scenario: Impressum is linked from footer +- **WHEN** a user views any page on the Journal or Planner +- **THEN** the footer contains a link to the Impressum + +### Requirement: Terms of Service page +The Journal SHALL serve a Terms of Service page at `/legal/terms` with clear disclaimers about the service's alpha state. + +#### Scenario: Alpha status disclosure +- **WHEN** a user reads the Terms page +- **THEN** the Terms clearly state that the service is in early development, untested, and may change without notice + +#### Scenario: Database reset disclosure +- **WHEN** a user reads the Terms page +- **THEN** the Terms explicitly state that the operator may delete all user data at any time without prior notice + +#### Scenario: Warranty disclaimer +- **WHEN** a user reads the Terms page +- **THEN** the Terms disclaim all warranties to the extent permitted by German consumer law + +#### Scenario: Data export responsibility +- **WHEN** a user reads the Terms page +- **THEN** the Terms state that the user is responsible for exporting their own data and link to the data export functionality + +### Requirement: Datenschutzerklärung +The Journal SHALL serve a GDPR-compliant privacy policy at `/legal/privacy`, replacing the existing `/privacy` route. + +#### Scenario: Controller contact +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** they see the name, address, and email of the data controller (Verantwortlicher per Art. 4 Nr. 7 DSGVO) + +#### Scenario: Legal basis for processing +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** each category of data processing lists its legal basis (e.g., Art. 6 Abs. 1 lit. b for contract, lit. f for legitimate interest) + +#### Scenario: Data subject rights +- **WHEN** a user reads the Datenschutzerklärung +- **THEN** they see their rights under GDPR: access (Art. 15), rectification (Art. 16), erasure (Art. 17), data portability (Art. 20), objection (Art. 21), and right to lodge a complaint with a supervisory authority + +#### Scenario: Redirect from old privacy URL +- **WHEN** a user navigates to `/privacy` +- **THEN** they are redirected to `/legal/privacy` with a 301 status + +### Requirement: Alpha banner +The Journal SHALL display a persistent banner notifying users that the service is in alpha, dismissible for the browser session. + +#### Scenario: Banner visible on first visit +- **WHEN** a user visits any Journal page for the first time in a session +- **THEN** a banner is shown with text stating trails.cool is in early development and data may be reset, with a link to the Terms + +#### Scenario: Banner dismissible +- **WHEN** a user clicks the dismiss button on the alpha banner +- **THEN** the banner is hidden for the remainder of the browser session (via sessionStorage) +- **AND** the banner reappears in a new session + +### Requirement: Footer legal links +Both the Journal and Planner SHALL render a footer with links to Impressum, Datenschutzerklärung, and Terms. + +#### Scenario: Footer on Journal +- **WHEN** a user views any Journal page +- **THEN** the footer contains links labeled "Impressum", "Privacy", and "Terms" pointing to the respective legal pages + +#### Scenario: Footer on Planner +- **WHEN** a user views any Planner page +- **THEN** the footer contains the same three legal links, pointing to the Journal instance (since the Planner is stateless and legal pages live on the Journal) From 6c1148d5b7c491f41355a06c517f4e9a5122c87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Sat, 18 Apr 2026 02:18:46 +0200 Subject: [PATCH 2/2] Quantize Overpass query bbox to align cache keys across clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Near-identical viewports from different collaborators (slightly different pan/zoom because Yjs awareness doesn't enforce pixel-exact alignment) previously produced byte-different Overpass queries and therefore different server-side cache keys. Each client missed cache and triggered its own upstream fetch. buildQuery now quantizes the bbox to a 0.01° grid (~1 km), expanding outward (south/west floor, north/east ceil) so the viewport is always covered, and formats coords with 3 decimals for a stable string. Trade-offs: - Slightly larger query bbox: ≤ one grid cell (≈ 1 km) of padding on each side. For a zoom-12 viewport (~11 km wide) that's <10% extra data; bounded by the existing `[maxsize:1048576]` + `out 100` limits. - Coordinates in the query are now 3-decimal strings regardless of the caller's precision — existing buildQuery test updated to match. Two new tests cover the cache-alignment property and the outward- expansion invariant. Hit ratio impact will be visible on the Grafana "Overpass Cache Hit Ratio" stat added in the prior PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/planner/app/lib/overpass.test.ts | 43 +++++++++++++++++++++++++-- apps/planner/app/lib/overpass.ts | 34 ++++++++++++++++++++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/planner/app/lib/overpass.test.ts b/apps/planner/app/lib/overpass.test.ts index b462e5b..0e284e3 100644 --- a/apps/planner/app/lib/overpass.test.ts +++ b/apps/planner/app/lib/overpass.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildQuery, parseResponse, deduplicateById, type Poi } from "./overpass.ts"; +import { buildQuery, parseResponse, deduplicateById, quantizeBbox, type Poi } from "./overpass.ts"; import { poiCategories } from "@trails-cool/map-core"; describe("buildQuery", () => { @@ -9,7 +9,7 @@ describe("buildQuery", () => { const query = buildQuery(bbox, categories); expect(query).toContain("[out:json]"); - expect(query).toContain("[bbox:52.5,13.3,52.6,13.5]"); + expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]"); expect(query).toContain('amenity"="drinking_water"'); expect(query).toContain("out center qt 100"); }); @@ -22,6 +22,45 @@ describe("buildQuery", () => { expect(query).toContain("drinking_water"); expect(query).toContain("camp_site"); }); + + it("produces identical queries for near-identical viewports within one grid cell", () => { + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const a = buildQuery( + { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, + categories, + ); + const b = buildQuery( + { south: 52.5039, west: 13.3078, north: 52.5925, east: 13.4921 }, + categories, + ); + expect(a).toBe(b); + }); + + it("expands the bbox outward so the original viewport is always covered", () => { + const categories = poiCategories.filter((c) => c.id === "drinking_water"); + const query = buildQuery( + { south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, + categories, + ); + expect(query).toContain("[bbox:52.500,13.300,52.600,13.500]"); + }); +}); + +describe("quantizeBbox", () => { + it("snaps south and west down, north and east up", () => { + const q = quantizeBbox({ south: 52.5041, west: 13.3072, north: 52.5931, east: 13.4928 }, 0.01); + expect(q.south).toBeCloseTo(52.5, 10); + expect(q.west).toBeCloseTo(13.3, 10); + expect(q.north).toBeCloseTo(52.6, 10); + expect(q.east).toBeCloseTo(13.5, 10); + }); + + it("is idempotent on already-aligned coordinates", () => { + const bbox = { south: 52.5, west: 13.3, north: 52.6, east: 13.5 }; + const q = quantizeBbox(bbox, 0.01); + expect(q.south).toBeCloseTo(52.5, 10); + expect(q.north).toBeCloseTo(52.6, 10); + }); }); describe("parseResponse", () => { diff --git a/apps/planner/app/lib/overpass.ts b/apps/planner/app/lib/overpass.ts index 6a14fbd..ac04eaa 100644 --- a/apps/planner/app/lib/overpass.ts +++ b/apps/planner/app/lib/overpass.ts @@ -2,6 +2,18 @@ import type { PoiCategory } from "@trails-cool/map-core"; const OVERPASS_PROXY = "/api/overpass"; +// Snap bbox coordinates to this grid before building the query so that two +// users looking at nearly-identical viewports produce byte-identical queries +// and therefore share a server-side cache entry. Expansion is outward +// (south/west floor, north/east ceil) so the user's viewport is always +// covered. ~0.01° ≈ 1 km at mid-latitudes. +const BBOX_GRID_STEP = 0.01; + +// Decimals used when formatting the quantized bbox into the query string. +// 3 decimals → 0.001° precision (~111 m) which is well below BBOX_GRID_STEP, +// so the string is a stable representation of the quantized cell. +const BBOX_DECIMALS = 3; + export interface Poi { id: number; lat: number; @@ -18,11 +30,31 @@ export interface BBox { east: number; } +/** + * Quantize a bbox to the grid defined by `BBOX_GRID_STEP`, expanding outward + * so the original bbox is fully contained. Returns a new BBox whose + * coordinates are cell-aligned. + */ +export function quantizeBbox(bbox: BBox, step: number = BBOX_GRID_STEP): BBox { + return { + south: Math.floor(bbox.south / step) * step, + west: Math.floor(bbox.west / step) * step, + north: Math.ceil(bbox.north / step) * step, + east: Math.ceil(bbox.east / step) * step, + }; +} + /** * Build an Overpass QL query combining all enabled categories into a union. + * The bbox is quantized to a fixed grid and formatted with a fixed number of + * decimals so near-identical viewports produce a byte-identical query — which + * the `/api/overpass` server-side cache keys on. */ export function buildQuery(bbox: BBox, categories: PoiCategory[]): string { - const bboxStr = `${bbox.south},${bbox.west},${bbox.north},${bbox.east}`; + const q = quantizeBbox(bbox); + const bboxStr = [q.south, q.west, q.north, q.east] + .map((n) => n.toFixed(BBOX_DECIMALS)) + .join(","); const unions = categories.map((c) => c.query).join(""); return `[out:json][timeout:10][maxsize:1048576][bbox:${bboxStr}];(${unions});out center qt 100;`; }