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;`; } 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)