Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ba01a95
feat: OSM trail data layer POC
claude Apr 26, 2026
3135f7c
test: trail route integration tests + PostGIS test database
claude Apr 26, 2026
5454a7d
fix: resolve Biome lint/format violations in trail files
claude Apr 26, 2026
7460c42
chore: update bun.lock after package install
claude Apr 26, 2026
3561128
fix: resolve TypeScript errors in trail routes and fixtures
claude Apr 26, 2026
d3f07df
fix: resolve all Biome errors and improve trail route type safety
claude Apr 26, 2026
a8eb7af
chore: merge development into osm-trail-poc branch
claude Apr 26, 2026
ae8a138
fix: address Copilot security/correctness review comments
claude Apr 26, 2026
197f2c1
fix: optional chain forecastday[0] access to satisfy strict null checks
claude Apr 26, 2026
58e16bd
refactor: move osm tooling into packages/osm-import workspace package
claude Apr 26, 2026
d982622
ci: retrigger CI after suspected transient runner failure
claude Apr 26, 2026
ff4a15c
chore: update bun.lock
claude Apr 26, 2026
d81ad49
refactor: generic osm_ways/osm_routes schema, sport column, drop cach…
claude Apr 26, 2026
94919cb
feat(osm-db,api,mcp): dedicated OSM Postgres package with importer an…
andrew-bierman Apr 26, 2026
53e809f
fix(tsconfig): remove deprecated baseUrl to fix TS5101 in TypeScript 6.0
claude Apr 26, 2026
771ce4e
fix(ci): resolve checks and API unit test failures
claude Apr 26, 2026
00b0d9e
fix(ci): fix checks failures - coverage exclude and package.json orde…
claude Apr 26, 2026
f14d00e
fix(api): remove osm_ways/osm_routes from app DB migration
andrew-bierman Apr 26, 2026
2877172
fix: add auth to all trail routes, fix SSRF in inline alltrails-preview
andrew-bierman Apr 26, 2026
cb8df9e
fix(osm): post-import indexes, Hyperdrive binding, stitching fallback…
andrew-bierman Apr 26, 2026
f92271d
refactor(osm-import): delegate post-import indexing to idempotent mig…
andrew-bierman Apr 26, 2026
027bc10
chore: merge development into claude/osm-trail-poc-TJrsB, resolve tra…
andrew-bierman Apr 26, 2026
2f02ab6
feat(admin): add trail viewer page with Leaflet map
andrew-bierman Apr 26, 2026
e572630
feat(osm-import): add sync script to push output tables to managed DB
andrew-bierman Apr 27, 2026
817371c
chore(osm-import): add .env.example with documented env vars
andrew-bierman Apr 27, 2026
ca73db6
refactor(osm-import): move env vars to root .env, rename MANAGED_DB_URL
andrew-bierman Apr 27, 2026
9405a67
refactor(osm-import): rename env vars to OSM_DATABASE_URL_LOCAL + OSM…
andrew-bierman Apr 27, 2026
8cbef59
feat(osm-import): add OSM_CACHE_MB env var for node cache tuning
andrew-bierman Apr 27, 2026
4665556
fix: address Copilot review comments
claude Apr 30, 2026
c92316a
test(api): harden OSM trail test suite with real-import scenarios
andrew-bierman Apr 27, 2026
af468f4
fix(trails): address Copilot review findings on OSM trail routes
andrew-bierman Apr 30, 2026
96b6880
fix(test): replace raw typeof check with isObject from @packrat/guards
andrew-bierman Apr 30, 2026
d80c8da
chore: merge origin/development into osm-trail-poc
andrew-bierman May 1, 2026
8c59615
fix(lint): resolve raw-regex pre-push violations from dev merge
andrew-bierman May 1, 2026
b991f22
fix(lint): allow raw process.env in OSM + drizzle infra scripts
andrew-bierman May 1, 2026
bb4af65
fix(types): add safe-cast annotations to pass strict cast check
andrew-bierman May 1, 2026
f9f6b63
fix(types): resolve TypeScript errors from new packages and admin code
claude May 1, 2026
d620eca
fix(admin): load leaflet from CDN to fix Cloudflare Workers build
claude May 1, 2026
4206538
chore(admin): add Next.js generated next-env.d.ts
claude May 1, 2026
a10ad73
fix: proper fixes for lint violations from dev merge
andrew-bierman May 1, 2026
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
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
# ===================================
NEON_DATABASE_URL=postgresql://username:password@host.region.aws.neon.tech/database?sslmode=require

# ===================================
# OSM TRAIL DATABASE
# ===================================
# Local Docker PostGIS used by osm2pgsql during import (scratch/processing DB).
# Not exposed publicly — only used by import and migration scripts.
OSM_DATABASE_URL_LOCAL=postgresql://packrat:packrat@localhost:5433/osm

# osm2pgsql node cache size in MB (default: 800).
# Increase for continent-scale imports to avoid disk-I/O stalls during way processing.
# Rule of thumb: ~40% of available RAM. For a 16 GB machine use 6000; for 32 GB use 12000.
# OSM_CACHE_MB=6000

# Managed production PostGIS (Supabase, Neon, RDS, etc.).
# Matches OSM_DATABASE_URL in the Worker (injected via Hyperdrive at runtime).
# When set, `bun run import` automatically syncs osm_ways + osm_routes here
# after a successful import. Can also be triggered manually with `bun run sync`.
# Leave unset to skip the sync step during local-only imports.
# OSM_DATABASE_URL=postgresql://user:password@host/osm?sslmode=require

# ===================================
# AUTHENTICATION & SECURITY
# ===================================
Expand Down
108 changes: 108 additions & 0 deletions apps/admin/app/dashboard/trails/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import { Button } from '@packrat/web-ui/components/button';
import { Input } from '@packrat/web-ui/components/input';
import { useQuery } from '@tanstack/react-query';
import { getTrailGeometry, type TrailGeometry } from 'admin-app/lib/api';
import { queryKeys } from 'admin-app/lib/queryKeys';
import dynamic from 'next/dynamic';
import { useState } from 'react';

const LEADING_SLASH_RE = /^r?\//;

const TrailMap = dynamic(() => import('admin-app/components/trail-map').then((m) => m.TrailMap), {
ssr: false,
loading: () => <div className="w-full h-full bg-muted animate-pulse rounded-lg" />,
});

function MetaBadge({ label, value }: { label: string; value: string | null | undefined }) {
if (!value) return null;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">{label}</span>
<span className="text-sm font-medium capitalize">{value}</span>
</div>
);
}

export default function TrailViewerPage() {
const [input, setInput] = useState('');
const [osmId, setOsmId] = useState('');

const { data, isLoading, isError, error } = useQuery<TrailGeometry>({
queryKey: queryKeys.osm.trail(osmId),
queryFn: () => getTrailGeometry(osmId),
enabled: osmId.length > 0,
retry: false,
});

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = input.trim().replace(LEADING_SLASH_RE, '');
if (trimmed) setOsmId(trimmed);
}

return (
<div className="flex flex-col gap-6 h-full">
<div>
<h2 className="text-2xl font-bold tracking-tight">Trail Viewer</h2>
<p className="text-muted-foreground text-sm mt-1">
Enter an OSM relation ID to visualise its geometry and verify stitching.
</p>
</div>

<form onSubmit={handleSubmit} className="flex gap-2 max-w-sm">
<Input
placeholder="OSM relation ID (e.g. 12345678)"
value={input}
onChange={(e) => setInput(e.target.value)}
className="font-mono"
/>
<Button type="submit" disabled={!input.trim()}>
Load
</Button>
</form>

{isError && (
<p className="text-destructive text-sm">
{error instanceof Error ? error.message : 'Failed to load trail'}
</p>
)}

{isLoading && <p className="text-muted-foreground text-sm animate-pulse">Loading trail…</p>}

{data && (
<>
<div className="flex flex-wrap gap-x-8 gap-y-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Name</span>
<span className="text-sm font-medium">
{data.name ?? <span className="text-muted-foreground italic">unnamed</span>}
</span>
</div>
<MetaBadge label="OSM ID" value={data.osmId} />
<MetaBadge label="Sport" value={data.sport} />
<MetaBadge label="Network" value={data.network} />
<MetaBadge label="Distance" value={data.distance} />
<MetaBadge label="Difficulty" value={data.difficulty} />
{!data.geometry && (
<span className="text-sm text-amber-600 font-medium self-end">
⚠ No geometry — stitching returned null
</span>
)}
</div>

<div className="flex-1 min-h-[500px]">
{data.geometry ? (
<TrailMap geometry={data.geometry} name={data.name} />
) : (
<div className="w-full h-full flex items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
No geometry available for this trail
</div>
)}
</div>
</>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions apps/admin/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { QueryProvider } from 'admin-app/components/query-provider';
import { ThemeProvider } from 'admin-app/components/theme-provider';
import type { Metadata } from 'next';
import { Mona_Sans as FontSans } from 'next/font/google';
import Script from 'next/script';
import type React from 'react';
import './globals.css';

Expand All @@ -28,6 +29,10 @@ export default function RootLayout({
}>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
{/* Leaflet CSS loaded from CDN — the JS bundle uses webpack externals (window.L) */}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body className={cn('min-h-screen bg-background font-sans antialiased', fontSans.variable)}>
<QueryProvider>
<ThemeProvider
Expand All @@ -39,6 +44,7 @@ export default function RootLayout({
{children}
</ThemeProvider>
</QueryProvider>
<Script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" strategy="afterInteractive" />
</body>
</html>
);
Expand Down
51 changes: 51 additions & 0 deletions apps/admin/components/trail-map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

import L from 'leaflet';
import { useEffect, useRef } from 'react';

interface TrailMapProps {
geometry: object;
name: string | null;
}

export function TrailMap({ geometry, name: _name }: TrailMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);

useEffect(() => {
if (!containerRef.current || mapRef.current) return;

const map = L.map(containerRef.current).setView([0, 0], 2);
mapRef.current = map;

L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);

return () => {
map.remove();
mapRef.current = null;
};
}, []);

useEffect(() => {
const map = mapRef.current;
if (!map) return;

map.eachLayer((layer: L.Layer) => {
if (layer instanceof L.GeoJSON) map.removeLayer(layer);
});

const layer = L.geoJSON(geometry as Parameters<typeof L.geoJSON>[0], {
style: { color: '#f97316', weight: 3, opacity: 0.9 },
}).addTo(map);

const bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [32, 32] });
}
}, [geometry]);

return <div ref={containerRef} className="w-full h-full rounded-lg" />;
}
6 changes: 6 additions & 0 deletions apps/admin/config/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BarChart2,
LayoutDashboard,
type LucideIcon,
Map as MapIcon,
Package,
Users,
} from 'lucide-react';
Expand Down Expand Up @@ -36,6 +37,11 @@ export const navItems: NavItem[] = [
href: '/dashboard/catalog',
icon: Package,
},
{
title: 'Trail Viewer',
href: '/dashboard/trails',
icon: MapIcon,
},
{
title: 'Platform Analytics',
href: '/dashboard/analytics/platform',
Expand Down
26 changes: 26 additions & 0 deletions apps/admin/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,29 @@ export function getCatalogEtl(limit = 20): Promise<EtlResponse> {
export function getCatalogEmbeddings(): Promise<EmbeddingStats> {
return adminFetch('/analytics/catalog/embeddings');
}

// ─── OSM Trail Viewer ─────────────────────────────────────────────────────────

async function trailsFetch<T>(path: string): Promise<T> {
const authHeaders = getAuthHeader();
const res = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...authHeaders },
});
if (!res.ok) throw new Error(`Trails API error: ${res.status} ${res.statusText}`);
return res.json() as Promise<T>; // safe-cast: fetch returns unknown JSON; caller is responsible for the shape via the generic T
}

export interface TrailGeometry {
osmId: string;
name: string | null;
sport: string | null;
network: string | null;
distance: string | null;
difficulty: string | null;
description: string | null;
geometry: object | null;
}

export function getTrailGeometry(osmId: string): Promise<TrailGeometry> {
return trailsFetch<TrailGeometry>(`/trails/${osmId}/geometry`);
}
5 changes: 5 additions & 0 deletions apps/admin/lib/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ export const queryKeys = {
breakdown: ['platform', 'breakdown'] as const,
},

/** OSM trail viewer queries. */
osm: {
trail: (osmId: string) => ['osm', 'trail', osmId] as const,
},

/** Catalog analytics queries. */
catalogAnalytics: {
overview: ['catalog', 'overview'] as const,
Expand Down
7 changes: 7 additions & 0 deletions apps/admin/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
17 changes: 17 additions & 0 deletions apps/admin/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@ const nextConfig = {
unoptimized: true,
},
transpilePackages: ['@packrat/web-ui'],
webpack: (config, { isServer }) => {
// Leaflet is loaded from CDN at runtime (see layout.tsx). Tell webpack to
// skip bundling leaflet in both the server and client passes so the build
// succeeds even when `leaflet` is absent from node_modules. The trail-map
// component uses `{ ssr: false }` so leaflet is never executed server-side.
if (isServer) {
const prev = Array.isArray(config.externals)
? config.externals
: config.externals
? [config.externals]
: [];
config.externals = [...prev, 'leaflet'];
} else {
config.externals = { leaflet: 'L' };
}
return config;
},
};

export default nextConfig;
3 changes: 3 additions & 0 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,16 @@
"@radix-ui/react-tabs": "catalog:",
"@radix-ui/react-tooltip": "catalog:",
"@tanstack/react-query": "^5.70.0",
"@types/leaflet": "^1.9.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.8.0",
"next": "^15.3.4",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:",
"react-leaflet": "^5.0.0",
"recharts": "3.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
Expand Down
1 change: 1 addition & 0 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"!**/ios",
"!**/android",
"!**/public",
"!**/coverage",
"!**/.next",
"!**/.wrangler",
"!**/*.gen.ts",
Expand Down
Loading
Loading