Merge branch 'main' into spec/multi-day-routes-journal
This commit is contained in:
commit
26cd8a8a0f
46 changed files with 955 additions and 607 deletions
21
apps/journal/app/components/ClientMap.tsx
Normal file
21
apps/journal/app/components/ClientMap.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useState, useEffect, type ComponentProps } from "react";
|
||||
|
||||
type MapProps = ComponentProps<typeof import("./RouteMapThumbnail.client").RouteMapThumbnail>;
|
||||
|
||||
export function ClientMap(props: MapProps) {
|
||||
const [Component, setComponent] = useState<React.ComponentType<MapProps>>();
|
||||
|
||||
useEffect(() => {
|
||||
import("./RouteMapThumbnail.client").then((m) => setComponent(() => m.RouteMapThumbnail));
|
||||
}, []);
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-gray-100 text-gray-500 ${props.className ?? "h-36 w-full rounded"}`}>
|
||||
Loading map...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
import { Suspense, lazy } from "react";
|
||||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/activities.$id";
|
||||
|
|
@ -7,10 +6,7 @@ import { getActivity, deleteActivity, linkActivityToRoute, createRouteFromActivi
|
|||
import { deleteImportByActivity } from "~/lib/sync/imports.server";
|
||||
import { listRoutes } from "~/lib/routes.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
const RouteMapThumbnail = lazy(() =>
|
||||
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
|
||||
);
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const activity = await getActivity(params.id);
|
||||
|
|
@ -125,9 +121,7 @@ export default function ActivityDetailPage({ loaderData }: Route.ComponentProps)
|
|||
|
||||
{activity.geojson && (
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
|
||||
<RouteMapThumbnail geojson={activity.geojson} interactive className="h-full w-full" />
|
||||
</Suspense>
|
||||
<ClientMap geojson={activity.geojson} interactive className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/activities._index";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { listActivities } from "~/lib/activities.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
const RouteMapThumbnail = lazy(() =>
|
||||
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
|
||||
);
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
|
|
@ -64,9 +60,7 @@ export default function ActivitiesListPage({ loaderData }: Route.ComponentProps)
|
|||
<div className="flex gap-4">
|
||||
<div className="w-48 shrink-0">
|
||||
{activity.geojson ? (
|
||||
<Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
|
||||
<RouteMapThumbnail geojson={activity.geojson} />
|
||||
</Suspense>
|
||||
<ClientMap geojson={activity.geojson} />
|
||||
) : (
|
||||
<div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
|
||||
{t("routes.noMapPreview")}
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
import { data } from "react-router";
|
||||
import { withDb } from "@trails-cool/db";
|
||||
|
||||
const version = process.env.SENTRY_RELEASE ?? "dev";
|
||||
|
||||
export async function loader() {
|
||||
try {
|
||||
await withDb(async () => {});
|
||||
return data({ status: "ok", version, db: "connected" });
|
||||
} catch {
|
||||
return data({ status: "degraded", version, db: "unreachable" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
import { useState, useCallback, Suspense, lazy } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { data, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/routes.$id";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { getRoute, getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
const RouteMapThumbnail = lazy(() =>
|
||||
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
|
||||
);
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
|
|
@ -161,9 +158,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
|
|||
|
||||
{route.geojson && (
|
||||
<div className="mt-6 overflow-hidden rounded-lg border border-gray-200" style={{ height: 400 }}>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center bg-gray-100 text-gray-500">Loading map...</div>}>
|
||||
<RouteMapThumbnail geojson={route.geojson} interactive className="h-full w-full" />
|
||||
</Suspense>
|
||||
<ClientMap geojson={route.geojson} interactive className="h-full w-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import { data, redirect } from "react-router";
|
||||
import { Suspense, lazy } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Route } from "./+types/routes._index";
|
||||
import { getSessionUser } from "~/lib/auth.server";
|
||||
import { listRoutes } from "~/lib/routes.server";
|
||||
import { ClientDate } from "~/components/ClientDate";
|
||||
|
||||
const RouteMapThumbnail = lazy(() =>
|
||||
import("~/components/RouteMapThumbnail").then((m) => ({ default: m.RouteMapThumbnail })),
|
||||
);
|
||||
import { ClientMap } from "~/components/ClientMap";
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const user = await getSessionUser(request);
|
||||
|
|
@ -62,9 +58,7 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
|
|||
<div className="flex gap-4">
|
||||
<div className="w-48 shrink-0">
|
||||
{route.geojson ? (
|
||||
<Suspense fallback={<div className="h-36 w-full animate-pulse rounded bg-gray-100" />}>
|
||||
<RouteMapThumbnail geojson={route.geojson} />
|
||||
</Suspense>
|
||||
<ClientMap geojson={route.geojson} />
|
||||
) : (
|
||||
<div className="flex h-36 w-full items-center justify-center rounded bg-gray-100 text-xs text-gray-400">
|
||||
{t("routes.noMapPreview")}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@
|
|||
"dependencies": {
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"@sentry/node": "^10.46.0",
|
||||
"@sentry/react": "^10.46.0",
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
|
|
@ -24,9 +24,9 @@
|
|||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"isbot": "^5.1.37",
|
||||
"jose": "^6.2.2",
|
||||
"nodemailer": "^8.0.4",
|
||||
"nodemailer": "^8.0.5",
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "catalog:",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createReadStream, statSync } from "node:fs";
|
|||
import { join, extname, resolve } from "node:path";
|
||||
import { logger } from "./app/lib/logger.server.ts";
|
||||
import { httpRequestDuration, registry } from "./app/lib/metrics.server.ts";
|
||||
import postgres from "postgres";
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const CLIENT_DIR = resolve(import.meta.dirname, "build", "client");
|
||||
|
|
@ -56,16 +57,16 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis
|
|||
const version = process.env.SENTRY_RELEASE ?? "dev";
|
||||
|
||||
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 });
|
||||
try {
|
||||
const { createDb } = await import("@trails-cool/db");
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const db = createDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
await client`SELECT 1`;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
|
||||
} catch {
|
||||
res.writeHead(503, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
"@geoman-io/leaflet-geoman-free": "^2.19.2",
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"@sentry/node": "^10.46.0",
|
||||
"@sentry/react": "^10.46.0",
|
||||
"@sentry/node": "^10.47.0",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@trails-cool/db": "workspace:*",
|
||||
"@trails-cool/gpx": "workspace:*",
|
||||
"@trails-cool/i18n": "workspace:*",
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
"@trails-cool/types": "workspace:*",
|
||||
"@trails-cool/ui": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"isbot": "^5.1.0",
|
||||
"isbot": "^5.1.37",
|
||||
"lib0": "^0.2.117",
|
||||
"pino": "^10.3.1",
|
||||
"prom-client": "^15.1.3",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
|
|||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, extname, resolve } from "node:path";
|
||||
import { setupYjsWebSocket } from "./app/lib/yjs-server.ts";
|
||||
import postgres from "postgres";
|
||||
|
||||
const sentryEnvironment = process.env.CI ? "ci" : (process.env.NODE_ENV ?? "development");
|
||||
|
||||
|
|
@ -74,16 +75,16 @@ async function handleMetrics(_req: IncomingMessage, res: ServerResponse): Promis
|
|||
const version = process.env.SENTRY_RELEASE ?? "dev";
|
||||
|
||||
async function handleHealth(_req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const client = postgres(process.env.DATABASE_URL ?? "postgres://trails:trails@localhost:5432/trails", { max: 1 });
|
||||
try {
|
||||
const { createDb } = await import("@trails-cool/db");
|
||||
const { sql } = await import("drizzle-orm");
|
||||
const db = createDb();
|
||||
await db.execute(sql`SELECT 1`);
|
||||
await client`SELECT 1`;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", version, db: "connected" }));
|
||||
} catch {
|
||||
res.writeHead(503, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "degraded", version, db: "unreachable" }));
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Photo upload via presigned URLs
|
||||
Users SHALL be able to upload photos to an activity using presigned PUT URLs, with client-side image processing before upload.
|
||||
|
||||
#### Scenario: Upload photo
|
||||
- **WHEN** a user selects a photo on the activity detail page
|
||||
- **THEN** the client resizes the image (max 2048px long edge), strips EXIF data, converts to WebP
|
||||
- **AND** requests a presigned PUT URL from `POST /api/activities/:id/photos`
|
||||
- **AND** uploads directly to Garage (S3-compatible storage)
|
||||
- **AND** confirms the upload via `PATCH /api/activities/:id/photos/:photoId`
|
||||
|
||||
#### Scenario: Size limit enforced
|
||||
- **WHEN** a processed photo exceeds 5 MB
|
||||
- **THEN** the presigned URL rejects the upload via content-length condition
|
||||
|
||||
#### Scenario: Per-activity photo limit
|
||||
- **WHEN** an activity already has 20 photos
|
||||
- **THEN** the server rejects new presigned URL requests
|
||||
|
||||
### Requirement: Photo gallery display
|
||||
The activity detail page SHALL display uploaded photos in a responsive grid with lightbox viewing.
|
||||
|
||||
#### Scenario: Gallery grid
|
||||
- **WHEN** a user views an activity with photos
|
||||
- **THEN** photos are displayed in a responsive grid (1 column mobile, 2 tablet, 3 desktop) with lazy loading
|
||||
|
||||
#### Scenario: Lightbox view
|
||||
- **WHEN** a user clicks a photo in the gallery
|
||||
- **THEN** a full-size lightbox overlay opens with left/right navigation and close on escape
|
||||
|
||||
### Requirement: Photo deletion
|
||||
Activity owners SHALL be able to delete photos with soft delete and background S3 cleanup.
|
||||
|
||||
#### Scenario: Delete photo
|
||||
- **WHEN** a user clicks the delete icon on a photo
|
||||
- **THEN** the photo status is set to 'deleted' and it disappears from the UI
|
||||
- **AND** the S3 object is cleaned up on next page load or periodic cleanup
|
||||
|
||||
### Requirement: Photo storage infrastructure
|
||||
Photos SHALL be stored in Garage (S3-compatible, self-hosted) running as a Docker container.
|
||||
|
||||
#### Scenario: Garage container
|
||||
- **WHEN** docker compose starts
|
||||
- **THEN** a Garage container is running with an `activity-photos` bucket
|
||||
|
||||
### Requirement: Photo privacy disclosure
|
||||
Photo storage and EXIF handling SHALL be documented in the privacy manifest.
|
||||
|
||||
#### Scenario: Privacy page updated
|
||||
- **WHEN** a user visits the privacy page
|
||||
- **THEN** it documents photo storage location, EXIF stripping, and deletion behavior
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Optional local monitoring stack
|
||||
The dev environment SHALL support an optional monitoring profile matching the production stack.
|
||||
|
||||
#### Scenario: Start with monitoring
|
||||
- **WHEN** a developer runs `pnpm dev:full` with `--profile monitoring`
|
||||
- **THEN** Prometheus, Grafana, and Loki start alongside the app services
|
||||
|
||||
### Requirement: Production-aligned PostgreSQL config
|
||||
The dev PostgreSQL SHALL match production configuration including pg_stat_statements.
|
||||
|
||||
#### Scenario: pg_stat_statements available
|
||||
- **WHEN** the dev PostgreSQL container starts
|
||||
- **THEN** pg_stat_statements is enabled via initialization scripts
|
||||
|
||||
### Requirement: Database seed script
|
||||
The dev environment SHALL provide a seed script for consistent test data.
|
||||
|
||||
#### Scenario: Seed database
|
||||
- **WHEN** a developer runs the seed script
|
||||
- **THEN** test users, routes, and activities are created in the local database
|
||||
|
||||
### Requirement: Dev environment reset
|
||||
The dev environment SHALL provide a command to tear down and recreate the local stack.
|
||||
|
||||
#### Scenario: Reset dev environment
|
||||
- **WHEN** a developer runs `pnpm dev:reset`
|
||||
- **THEN** all Docker volumes are removed, containers are recreated, and the database is re-seeded
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Overnight waypoint markers
|
||||
Any waypoint SHALL be toggleable as an overnight stop, creating day boundaries in the route.
|
||||
|
||||
#### Scenario: Toggle overnight
|
||||
- **WHEN** a user toggles the overnight flag on a waypoint
|
||||
- **THEN** the waypoint's `overnight: true` flag is set in the Yjs document
|
||||
- **AND** the route is visually divided into days at that point
|
||||
|
||||
#### Scenario: Implicit day boundaries
|
||||
- **WHEN** overnight stops are set
|
||||
- **THEN** the first waypoint is the implicit start of Day 1 and the last waypoint is the implicit end of the final day
|
||||
|
||||
### Requirement: Per-day statistics
|
||||
The Planner SHALL compute and display distance, ascent, and estimated duration for each day.
|
||||
|
||||
#### Scenario: Day stats computed
|
||||
- **WHEN** a route has overnight waypoints
|
||||
- **THEN** per-day distance, total ascent, and estimated duration are derived from segment boundaries and coordinates
|
||||
|
||||
### Requirement: Day-aware sidebar
|
||||
The sidebar SHALL group waypoints by day with collapsible sections and per-day stats.
|
||||
|
||||
#### Scenario: Day breakdown
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** waypoints are grouped under "Day 1", "Day 2", etc. with collapsible sections
|
||||
- **AND** each section header shows day distance and ascent
|
||||
|
||||
### Requirement: Elevation chart day dividers
|
||||
The elevation chart SHALL show day boundaries as dashed vertical lines.
|
||||
|
||||
#### Scenario: Day dividers on chart
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** dashed vertical lines with "Day N" labels appear at each overnight waypoint position
|
||||
|
||||
### Requirement: Map day labels
|
||||
The map SHALL display day summary labels at day boundary waypoints.
|
||||
|
||||
#### Scenario: Day labels on map
|
||||
- **WHEN** a route has multiple days
|
||||
- **THEN** white pill markers at day boundaries show "Day N . X km"
|
||||
|
||||
### Requirement: Multi-day GPX export
|
||||
Day structure SHALL be preserved in GPX exports via waypoint type elements.
|
||||
|
||||
#### Scenario: Export multi-day route
|
||||
- **WHEN** a user exports a plan with overnight waypoints
|
||||
- **THEN** overnight waypoints include a `<type>overnight</type>` element in the GPX
|
||||
- **AND** reimporting the GPX restores the day structure
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Map-based route exploration
|
||||
The Journal SHALL provide an explore page with a full-page map for browsing public routes by location.
|
||||
|
||||
#### Scenario: Browse public routes
|
||||
- **WHEN** a user visits `/routes/explore`
|
||||
- **THEN** a full-page Leaflet map is displayed
|
||||
- **AND** public routes within the current viewport are shown as clickable polylines
|
||||
|
||||
#### Scenario: Route popup
|
||||
- **WHEN** a user clicks a route polyline on the explore map
|
||||
- **THEN** a popup shows the route name, distance, elevation gain, author, and a link to the detail page
|
||||
|
||||
### Requirement: Bounding box spatial query
|
||||
The Journal SHALL provide an API endpoint for querying public routes by map viewport bounds.
|
||||
|
||||
#### Scenario: Viewport query
|
||||
- **WHEN** the map viewport changes
|
||||
- **THEN** the client requests routes intersecting the current bounds via PostGIS `ST_Intersects`
|
||||
- **AND** results are limited to 50 routes with simplified geometries
|
||||
|
||||
### Requirement: Spatial index
|
||||
The routes table SHALL have a GiST index on the geometry column for fast bounding box queries.
|
||||
|
||||
#### Scenario: Index exists
|
||||
- **WHEN** the database is set up
|
||||
- **THEN** a GiST index on `journal.routes.geom` supports efficient spatial queries
|
||||
38
openspec/changes/route-sharing/specs/route-sharing/spec.md
Normal file
38
openspec/changes/route-sharing/specs/route-sharing/spec.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Route visibility
|
||||
Routes and activities SHALL support private (default) and public visibility levels.
|
||||
|
||||
#### Scenario: Set route public
|
||||
- **WHEN** a route owner toggles visibility to "public"
|
||||
- **THEN** the route is viewable by anyone with the link
|
||||
|
||||
#### Scenario: Default private
|
||||
- **WHEN** a new route is created
|
||||
- **THEN** its visibility is "private" (owner-only)
|
||||
|
||||
### Requirement: Per-user route sharing
|
||||
Route owners SHALL be able to share routes with specific users at view or edit permission levels.
|
||||
|
||||
#### Scenario: Share with user
|
||||
- **WHEN** a route owner shares a route with another user at "view" level
|
||||
- **THEN** the shared user sees the route in their collection and can view it
|
||||
|
||||
#### Scenario: Edit permission
|
||||
- **WHEN** a route owner shares a route with "edit" permission
|
||||
- **THEN** the shared user can update the route name, description, and GPX
|
||||
|
||||
### Requirement: Route forking
|
||||
Logged-in users SHALL be able to fork any public route into their own collection.
|
||||
|
||||
#### Scenario: Fork public route
|
||||
- **WHEN** a user clicks "Fork" on a public route
|
||||
- **THEN** a copy of the route metadata and latest GPX is created in their collection
|
||||
- **AND** a `forkedFromId` link references the original route
|
||||
|
||||
### Requirement: Contributor tracking
|
||||
Route versions SHALL record the identity of the contributor who saved them.
|
||||
|
||||
#### Scenario: Planner callback contributor
|
||||
- **WHEN** the Planner saves back to the Journal via JWT callback
|
||||
- **THEN** the contributor's identity is recorded on the new route version
|
||||
40
openspec/changes/visual-redesign/specs/design-system/spec.md
Normal file
40
openspec/changes/visual-redesign/specs/design-system/spec.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Design token system
|
||||
The Planner SHALL use CSS custom properties for colors, typography, and spacing with Tailwind config extensions.
|
||||
|
||||
#### Scenario: Consistent styling
|
||||
- **WHEN** a component is styled
|
||||
- **THEN** it uses project design tokens (warm off-whites, sage green accent, earthy tones) via Tailwind classes
|
||||
|
||||
#### Scenario: Typography
|
||||
- **WHEN** text is rendered
|
||||
- **THEN** body text uses Outfit font and statistics use Geist Mono
|
||||
|
||||
### Requirement: Redesigned topbar
|
||||
The Planner topbar SHALL include a logo, segmented color mode toggle, participant avatars, and export controls.
|
||||
|
||||
#### Scenario: Topbar layout
|
||||
- **WHEN** a user is in a planning session
|
||||
- **THEN** the topbar shows: logo with mountain mark, color mode toggle, participant avatars with names, and Export GPX button
|
||||
|
||||
### Requirement: Redesigned sidebar
|
||||
The Planner sidebar SHALL display a route summary header with stats, collapsible day breakdown, and waypoints nested inside days.
|
||||
|
||||
#### Scenario: Route summary
|
||||
- **WHEN** a route is computed
|
||||
- **THEN** the sidebar header shows route name, distance, ascent, and estimated duration
|
||||
|
||||
### Requirement: Redesigned map markers
|
||||
Map waypoint markers SHALL use olive/dark numbered circles with overnight badges on stop waypoints.
|
||||
|
||||
#### Scenario: Marker styling
|
||||
- **WHEN** waypoints are displayed on the map
|
||||
- **THEN** they appear as dark numbered circles instead of default blue markers
|
||||
|
||||
### Requirement: Mobile responsive layout
|
||||
The Planner SHALL use a bottom sheet pattern on mobile instead of a hidden sidebar.
|
||||
|
||||
#### Scenario: Mobile view
|
||||
- **WHEN** the Planner is viewed on a mobile device
|
||||
- **THEN** a bottom sheet replaces the sidebar with swipeable tabs (Days/Waypoints/Notes)
|
||||
46
openspec/changes/waypoint-notes/specs/waypoint-notes/spec.md
Normal file
46
openspec/changes/waypoint-notes/specs/waypoint-notes/spec.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Per-waypoint text notes
|
||||
Each waypoint SHALL support an optional plain-text note synced via Yjs.
|
||||
|
||||
#### Scenario: Add note to waypoint
|
||||
- **WHEN** a user clicks the note area under a waypoint in the sidebar and types text
|
||||
- **THEN** the note is stored in the waypoint's Y.Map as a `note` string field
|
||||
- **AND** auto-saves on blur
|
||||
|
||||
#### Scenario: Note syncs to participants
|
||||
- **WHEN** a user adds or edits a waypoint note
|
||||
- **THEN** all other participants see the update in real-time via Yjs
|
||||
|
||||
### Requirement: Map note indicators
|
||||
Waypoint markers with notes SHALL show a visual indicator on the map.
|
||||
|
||||
#### Scenario: Note icon on marker
|
||||
- **WHEN** a waypoint has a note
|
||||
- **THEN** its map marker shows a small note icon
|
||||
|
||||
#### Scenario: Note tooltip
|
||||
- **WHEN** a user hovers or taps a marker with a note
|
||||
- **THEN** the note text appears in a tooltip
|
||||
|
||||
### Requirement: Notes in GPX export
|
||||
Waypoint notes SHALL be exported as `<desc>` elements in GPX output.
|
||||
|
||||
#### Scenario: Export notes
|
||||
- **WHEN** a user exports a plan with waypoint notes
|
||||
- **THEN** each waypoint's note appears as a `<desc>` element in the GPX file
|
||||
|
||||
### Requirement: Nearby POI display
|
||||
When a waypoint is selected, nearby POIs from OpenStreetMap SHALL be shown on the map and in the sidebar.
|
||||
|
||||
#### Scenario: POI lookup
|
||||
- **WHEN** a user selects a waypoint
|
||||
- **THEN** nearby POIs are fetched from the Overpass API and displayed as small markers on the map and as a list in the sidebar
|
||||
|
||||
### Requirement: Snap waypoint to POI
|
||||
Users SHALL be able to move a waypoint to a nearby POI's exact coordinates.
|
||||
|
||||
#### Scenario: Snap to POI
|
||||
- **WHEN** a user clicks a nearby POI
|
||||
- **THEN** the waypoint moves to the POI's coordinates
|
||||
- **AND** the POI's name and type are added as a note prefix (e.g., "Campsite - Waldcamp Fichtelberg")
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
## MODIFIED Requirements
|
||||
## Purpose
|
||||
|
||||
User account settings page with connected services management, profile editing, and security controls.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Connected Services section
|
||||
The settings page SHALL include a "Connected Services" section for managing external integrations.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Activity creation, chronological feed, detail views, and route linking in the Journal app.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Create activity
|
||||
The Journal SHALL allow authenticated users to create an activity by uploading a GPX trace and adding a description.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Route computation between waypoints via the BRouter HTTP API, including routing host election, result broadcasting via Yjs, profile selection, and rate-limited proxying.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Route computation from waypoints
|
||||
The Planner SHALL compute a route between ordered waypoints by calling the BRouter HTTP API with tiledesc enabled and returning the result as an EnrichedRoute, preserving per-point elevation, surface data, and segment boundary indices.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Browser crash resilience via localStorage-based Yjs state persistence and recovery on reconnect.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: localStorage crash recovery
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
GPX file import in the Planner via home page file picker and in-session drag-and-drop, with client-side parsing and waypoint extraction.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Import GPX from home page
|
||||
Users SHALL be able to import a GPX file from the planner home page to start a new planning session.
|
||||
|
|
@ -30,7 +34,9 @@ Users SHALL be able to drag a GPX file onto the map in an existing session.
|
|||
- **WHEN** a user drops a GPX file and the confirmation dialog appears
|
||||
- **THEN** clicking "Cancel" leaves the session unchanged
|
||||
|
||||
### Requirement: Non-GPX files are rejected
|
||||
### Requirement: Non-GPX file rejection
|
||||
The Planner SHALL reject non-GPX files dropped on the map.
|
||||
|
||||
#### Scenario: Drop non-GPX file
|
||||
- **WHEN** a user drops a non-GPX file on the map
|
||||
- **THEN** the file is ignored with a brief error toast
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Server provisioning on Hetzner, Docker Compose deployment, CI/CD pipelines, database and BRouter management, TLS, Sentry, Grafana, and monitoring stack for the flagship instance.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Terraform Hetzner provisioning
|
||||
Infrastructure SHALL be provisioned on Hetzner Cloud using Terraform with the Hetzner provider.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## MODIFIED Requirements
|
||||
## Purpose
|
||||
|
||||
Authentication for the Journal app, including OAuth token storage for external services in the sync_connections table.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Store external service tokens
|
||||
The journal auth system SHALL store OAuth tokens for external services alongside user credentials.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
One-command local development setup with PostgreSQL, BRouter, automatic schema migration, and segment downloading.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: One-command dev startup
|
||||
The project SHALL provide a single command that starts all services needed for local development.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Interactive map rendering with Leaflet and OSM tiles, waypoint editing, route visualization, elevation profiles, multiplayer cursors, and polygon drawing for both Planner and Journal apps.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Map rendering with OSM tiles
|
||||
The Planner and Journal SHALL render interactive maps using Leaflet with OpenStreetMap tiles as the default base layer.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Polygon-based route avoidance areas drawn on the map, synced via Yjs, sent to BRouter as constraints, and persisted in GPX extensions.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Draw no-go areas
|
||||
|
|
@ -35,22 +39,16 @@ No-go areas SHALL be preserved when saving to the journal or exporting a plan.
|
|||
- **AND** the file is compatible with any GPX-consuming application
|
||||
|
||||
### Requirement: GPX extensions format
|
||||
No-go areas are stored in GPX using a custom XML namespace:
|
||||
No-go areas SHALL be stored in GPX using the `trails:planning` custom XML namespace with point-based polygon representation.
|
||||
|
||||
```xml
|
||||
<gpx xmlns:trails="https://trails.cool/gpx/1">
|
||||
<extensions>
|
||||
<trails:planning>
|
||||
<trails:nogo>
|
||||
<trails:point lat="52.5" lon="13.3"/>
|
||||
<trails:point lat="52.4" lon="13.4"/>
|
||||
<trails:point lat="52.3" lon="13.2"/>
|
||||
</trails:nogo>
|
||||
</trails:planning>
|
||||
</extensions>
|
||||
</gpx>
|
||||
```
|
||||
#### Scenario: Valid no-go area in GPX
|
||||
- **WHEN** a GPX file contains a `<trails:nogo>` element with 3+ `<trails:point>` child elements
|
||||
- **THEN** the parser creates a no-go area polygon from the point coordinates
|
||||
|
||||
- Each `<trails:nogo>` element contains 3+ `<trails:point>` elements
|
||||
- Parser accepts both namespaced (`trails:nogo`) and non-namespaced (`nogo`) elements
|
||||
- Areas with fewer than 3 points are rejected on parse
|
||||
#### Scenario: Namespace-agnostic parsing
|
||||
- **WHEN** a GPX file contains non-namespaced `<nogo>` elements instead of `<trails:nogo>`
|
||||
- **THEN** the parser accepts them identically
|
||||
|
||||
#### Scenario: Invalid no-go area rejected
|
||||
- **WHEN** a GPX file contains a no-go area with fewer than 3 points
|
||||
- **THEN** the parser rejects it
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Health endpoints, Prometheus metrics, structured logging, Grafana dashboards, and alerting for both apps and the flagship instance.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Health endpoints
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
## MODIFIED Requirements
|
||||
## Purpose
|
||||
|
||||
Round-trip GPX exchange between Planner and Journal, including JWT-scoped callbacks for saving routes and GPX reimport in the Planner.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Export Plan reimport
|
||||
The "Export Plan" GPX can now be reimported directly in the planner via the file upload UI, completing the round-trip without needing the journal.
|
||||
The Planner SHALL support reimporting an exported plan GPX via the file upload UI, completing the round-trip without needing the journal.
|
||||
|
||||
#### Scenario: Reimport exported plan
|
||||
- **WHEN** a user exports a plan and later imports it via the planner's GPX upload
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
## MODIFIED Requirements
|
||||
## Purpose
|
||||
|
||||
### Requirement: Session initialization
|
||||
Sessions can now be initialized from a GPX file upload in addition to URL parameters and the journal handoff.
|
||||
Collaborative planning session management with Yjs CRDT synchronization, supporting initialization from URL parameters, journal handoff, and GPX file upload.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Session initialization from GPX
|
||||
The Planner SHALL support initializing sessions from a GPX file upload in addition to URL parameters and the journal handoff.
|
||||
|
||||
#### Scenario: Session created from GPX upload
|
||||
- **WHEN** a session is created via GPX file upload on the home page
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Request rate limiting for Planner session creation and BRouter API calls to prevent abuse.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Session creation rate limit
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Multi-mode route visualization (plain, elevation gradient, surface type) with session-synced color mode selection.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Route color modes
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Ghost marker drag interaction for reshaping routes by inserting new waypoints mid-segment.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Ghost marker on route hover
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Route CRUD operations, GPX import/export, sequential versioning, PostGIS spatial storage, and route metadata in the Journal app.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Create route
|
||||
The Journal SHALL allow authenticated users to create a new route with a name and optional description.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Map thumbnails on route and activity list pages, and interactive maps with zoom and layer switching on detail pages.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Route map preview on list pages
|
||||
Route and activity list pages SHALL show a small map thumbnail for each item that has geometry.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Waypoint insertion by clicking the ghost marker on the route polyline, splitting a segment into two.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Insert waypoint by clicking on route
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
SOPS-encrypted secrets stored in the repository, split into app and infra files, decryptable with a single age key at deploy time.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Encrypted secrets in repository
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Security headers, scanner path blocking, secret scanning, dependency auditing, non-root containers, and vulnerability disclosure policy.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Security response headers
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Collaborative text notes in Planner sessions, synced in real-time via Yjs.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Collaborative session notes
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Shared TypeScript packages (@trails-cool/types, gpx, map, ui, i18n) used by both Planner and Journal apps.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Shared types package
|
||||
The `@trails-cool/types` package SHALL export TypeScript interfaces for Route, Activity, Waypoint, RouteVersion, and RouteMetadata used by both apps.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
## Purpose
|
||||
|
||||
Transactional email sending with HTML and plain-text templates for magic links and welcome messages, using SMTP in production and console logging in dev.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Email sending interface
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
## ADDED Requirements
|
||||
## Purpose
|
||||
|
||||
Provider-agnostic activity sync framework with Wahoo as the first provider, supporting OAuth connection, webhook-based automatic sync, manual import, and FIT-to-GPX conversion.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Provider-agnostic sync framework
|
||||
The system SHALL provide a common interface for external activity sync providers.
|
||||
|
|
|
|||
27
package.json
27
package.json
|
|
@ -26,7 +26,8 @@
|
|||
"overrides": {
|
||||
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
|
||||
"brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
|
||||
"path-to-regexp@<0.1.13": "0.1.13"
|
||||
"path-to-regexp@<0.1.13": "0.1.13",
|
||||
"lodash@<4.18.1": "4.18.1"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@sentry/cli",
|
||||
|
|
@ -35,10 +36,10 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@react-router/dev": "^7.13.2",
|
||||
"@react-router/node": "^7.13.2",
|
||||
"@react-router/serve": "^7.13.2",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@react-router/dev": "catalog:",
|
||||
"@react-router/node": "catalog:",
|
||||
"@react-router/serve": "catalog:",
|
||||
"@sentry/vite-plugin": "^5.1.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
|
@ -50,25 +51,25 @@
|
|||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-postgis": "^1.1.1",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"fit-file-parser": "^2.3.3",
|
||||
"i18next": "^26.0.1",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"jsdom": "^29.0.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"linkedom": "^0.18.12",
|
||||
"playwright": "^1.58.2",
|
||||
"postgres": "^3.4.8",
|
||||
"playwright": "^1.59.1",
|
||||
"postgres": "catalog:",
|
||||
"prettier": "^3.8.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router": "^7.13.2",
|
||||
"react-router": "catalog:",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"turbo": "^2.8.21",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"turbo": "^2.9.3",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "catalog:",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
|
|
|
|||
967
pnpm-lock.yaml
generated
967
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,20 +5,20 @@ packages:
|
|||
catalog:
|
||||
react: ^19.1.0
|
||||
react-dom: ^19.1.0
|
||||
react-router: ^7.13.2
|
||||
"@react-router/dev": ^7.13.2
|
||||
"@react-router/node": ^7.13.2
|
||||
"@react-router/serve": ^7.13.2
|
||||
react-router: ^7.14.0
|
||||
"@react-router/dev": ^7.14.0
|
||||
"@react-router/node": ^7.14.0
|
||||
"@react-router/serve": ^7.14.0
|
||||
"@types/react": ^19.1.4
|
||||
"@types/react-dom": ^19.1.5
|
||||
tailwindcss: ^4.1.7
|
||||
"@tailwindcss/vite": ^4.1.7
|
||||
typescript: ^5.8.3
|
||||
vite: ^6.0.0
|
||||
vite: ^6.4.2
|
||||
drizzle-orm: ^0.45.2
|
||||
drizzle-kit: ^0.31.0
|
||||
drizzle-postgis: ^1.1.0
|
||||
postgres: ^3.4.0
|
||||
postgres: ^3.4.9
|
||||
|
||||
overrides:
|
||||
esbuild: ">=0.25.0"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue