diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0181b8a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.next +.git +.github +*.md +!README.md +.env*.local +data/*.db +data/*.db-wal +data/*.db-shm diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0ae6982 --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# UDC Water Resources Dashboard — Environment Variables +# Copy this file to .env.local and fill in your values + +# ---------- Database ---------- +# Option 1: SQLite (local development, default) +# DB_PATH=./data/udc-water.db + +# Option 2: Neon PostgreSQL (production / Vercel) +# When DATABASE_URL is set, the app uses PostgreSQL instead of SQLite. +# DATABASE_URL=postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/neondb?sslmode=require + +# ---------- API Security ---------- +# API key for the /api/ingest endpoint (optional locally, required in production) +# Generate with: openssl rand -hex 32 +# INGEST_API_KEY=your-secret-key-here +# ---------- AI Research Assistant ---------- +# Required for the AI chat feature (optional — dashboard works without it) +# Get your key from: https://console.anthropic.com +# ANTHROPIC_API_KEY=sk-ant-xxx + +# ---------- Admin Panel ---------- +# Required for /admin access in production (optional locally — open access in dev) +# Generate with: openssl rand -hex 32 +# ADMIN_API_KEY=your-admin-key-here diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a93cb6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Seed database + run: npm run db:seed + + - name: Run tests + run: npm test + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore index 574e3f9..731634a 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* +# database +/data/*.db +/data/*.db-wal +/data/*.db-shm + # env files .env*.local diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e81f35b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,122 @@ +# UDC Water Resources Data Dashboard - Project Memory + +## Rules for Claude Code + +### Security — NEVER Expose Credentials +- **NEVER** commit, log, or output credentials, API keys, database connection strings, passwords, or tokens +- **NEVER** hardcode secrets in source files — always use environment variables +- When referencing `DATABASE_URL`, `INGEST_API_KEY`, or similar, use placeholder values like `postgresql://user:password@host/db` +- If a user shares a credential in conversation, do NOT echo it back in code, commits, or file contents +- `.env.local` is gitignored — secrets belong there, never in tracked files + +### Git Commit & Push Rules +- **NEVER** include Claude Code session attribution URLs in commit messages, PR descriptions, or code comments +- **NEVER** include any `https://claude.ai/code/...` links anywhere in the codebase or git history +- Never amend someone else's commit — always create new commits +- Write clear, descriptive commit messages summarizing the "why" + +## Project Overview +Interactive water quality monitoring dashboard for UDC's Water Resources Research Institute (WRRI) and CAUSES. +Built with Next.js 16.1.6 (App Router), TypeScript, Tailwind CSS 4, Leaflet, Recharts, React 19. + +## Current State (as of March 2026 audit) +- **17 TSX/TS component files**, 2 data files, 4 app pages, 12 monitoring stations +- **Entirely static/client-side** — no backend, no database, no real APIs +- **Geospatial data** derived from official DC GIS government sources (verified) +- **Theme system** working (dark/light/system) with localStorage persistence + +## Production Readiness Audit — Issues to Address + +### Phase 1: Critical (Error Handling & Testing) — DONE +- [x] **Error Boundary** — `src/components/ErrorBoundary.tsx` wraps app in layout.tsx +- [x] **Testing** — Vitest configured, 17 tests across 4 suites (data, error boundary, health, validation) +- [x] **CI/CD** — `.github/workflows/ci.yml` (test + build on push/PR) +- [x] **Health check** — `GET /api/health` returns status, timestamp, version, uptime +- [x] **Functional search** — Header search filters stations, research, pages; tooltips on placeholder buttons + +### Phase 2: Important (Logging, Monitoring, Validation) — DONE +- [x] **Logger utility** — `src/lib/logger.ts` with buffered client-side logging (info/warn/error) +- [x] **Input validation** — `src/lib/validation.ts` with XSS sanitization, applied to Header search +- [x] **Deployment docs** — README updated with Docker, Vercel, health check, and testing instructions +- [x] **Security headers** — CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy in next.config.ts + +### Phase 3: Backend & Data — DONE (Local SQLite, Azure-ready) +- [x] **Database** — SQLite via better-sqlite3 (`data/udc-water.db`), schema in `src/lib/db.ts` +- [x] **Seed script** — `npm run db:seed` populates DB from static data (12 stations, 144 readings) +- [x] **API routes** — `GET /api/stations`, `GET /api/stations/:id/history`, `GET /api/export` +- [x] **Data export** — CSV and JSON export via `/api/export?format=csv&station=ANA-001` +- [x] **USGS ingestion** — `POST /api/ingest?source=usgs` fetches real USGS NWIS instantaneous values +- [x] **Ingestion logging** — `ingestion_log` table tracks all ingest runs with status and error messages +- [x] **Neon PostgreSQL** — `@neondatabase/serverless` + `ws`; `DATABASE_URL` env var switches from SQLite +- [ ] **Cron scheduling** — Set up Azure Functions Timer or Vercel Cron for automated ingestion +- [x] **Frontend migration** — StationTable, MetricCards, station detail page fetch from API with static fallback + +### Phase 5: AI Research Assistant — DONE +- [x] **AI Chat API** — `POST /api/chat` with Claude via Vercel AI SDK v6 +- [x] **Domain system prompt** — EPA thresholds, seasonal patterns, station metadata, WRRI research context +- [x] **Tool-augmented** — AI can query `/api/stations`, `/api/stations/:id/history` for live data +- [x] **Chat UI** — Floating panel (`ResearchAssistant.tsx`) with streaming, suggested questions, clear history +- [x] **Graceful degradation** — Shows config message when `ANTHROPIC_API_KEY` not set +- [ ] **RAG expansion** — Vector search over research papers and USGS reports (future) +- [ ] **Chart generation** — AI-generated plots from query results (future) + +### Phase 4: Nice-to-Have — DONE (Docker, Docs) +- [x] Contributing guidelines — `CONTRIBUTING.md` +- [x] Architecture diagrams — ASCII diagram in README +- [x] Docker/Kubernetes configs — `Dockerfile`, `docker-compose.yml`, `.dockerignore` +- [ ] User authentication/authorization (currently API-key based) + +### Phase 6: Faculty Admin Panel — DONE +- [x] **Admin page** — `/admin` with auth gate (ADMIN_API_KEY env var) +- [x] **CSV/JSON upload** — Drag-and-drop with auto column mapping + validation +- [x] **AI-assisted column mapping** — Claude Haiku maps non-standard column names to schema (falls back to heuristics) +- [x] **Station CRUD** — Add, edit, delete stations from `/api/admin/stations` +- [x] **Readings CRUD** — View, add, delete readings from `/api/admin/readings` with pagination +- [x] **Ingestion trigger** — Run USGS/EPA ingestion from admin UI +- [x] **Ingestion log viewer** — Full history of all data imports +- [x] **Sidebar link** — "Data Admin" nav item under ADMIN section + +## Database Setup +- **Local dev**: SQLite via better-sqlite3 (default, no config needed) +- **Production**: Neon PostgreSQL — set `DATABASE_URL` env var on Vercel +- **Seed Neon**: `DATABASE_URL=postgresql://... npx tsx scripts/seed.ts` +- **Seed local**: `npx tsx scripts/seed.ts` (or `npm run db:seed`) +- **Schema**: Dual schemas in `src/lib/db.ts` (SQLite + PostgreSQL), auto-selected +- **Future**: Optionally migrate to Azure PostgreSQL (same `DATABASE_URL` swap) + +## Key Files +- `src/app/page.tsx` — Main dashboard +- `src/app/layout.tsx` — Root layout with ThemeProvider + ErrorBoundary +- `src/app/station/[id]/page.tsx` — Station detail pages +- `src/app/api/stations/route.ts` — Stations list API +- `src/app/api/stations/[id]/history/route.ts` — Station history API +- `src/app/api/export/route.ts` — CSV/JSON data export +- `src/app/api/ingest/route.ts` — USGS data ingestion +- `src/app/api/chat/route.ts` — AI research assistant (Claude via Vercel AI SDK) +- `src/app/api/health/route.ts` — Health check endpoint +- `src/app/admin/page.tsx` — Faculty admin panel (upload, CRUD, logs) +- `src/app/api/admin/upload/route.ts` — CSV/JSON upload with auto column mapping +- `src/app/api/admin/stations/route.ts` — Station CRUD API +- `src/app/api/admin/readings/route.ts` — Readings CRUD API +- `src/app/api/admin/ai-map-columns/route.ts` — AI-powered column mapping +- `src/lib/db.ts` — Database abstraction (SQLite + Neon PostgreSQL) +- `src/lib/logger.ts` — Client-side logging utility +- `src/lib/validation.ts` — Input sanitization +- `src/components/ai/ResearchAssistant.tsx` — AI chat panel (floating widget) +- `src/components/map/DCMap.tsx` — Interactive Leaflet map (dynamic import, SSR disabled) +- `src/components/layout/Header.tsx` — Top bar with functional search +- `src/components/ErrorBoundary.tsx` — React error boundary +- `src/data/dc-waterways.ts` — 801 lines: stations, waterways, research, EJ data +- `src/data/dc-boundaries.ts` — Ward polygons, watershed, flood zones +- `scripts/seed.ts` — Database seed script + +## Tech Notes +- Leaflet map uses `dynamic()` with `ssr: false` (required for client-only rendering) +- Tailwind v4 uses `@theme` directive with CSS variables for UDC brand colors (#FDB927 gold, #CE1141 red, #002B5C navy) +- Theme persisted in localStorage key: `udc-theme` +- Next.js standalone output configured in `next.config.ts` +- AI assistant requires `ANTHROPIC_API_KEY` env var (optional — dashboard works without it) +- Admin panel uses `ADMIN_API_KEY` env var for access control (no key = open access in dev) +- AI column mapping in admin uses Claude Haiku (fast/cheap) with heuristic fallback +- AI SDK v6: uses `tool()`, `stepCountIs()`, `DefaultChatTransport`, `sendMessage` (not v4/v5 API) +- All external resources use HTTPS (CartoDB tiles, Leaflet CDN, Google Fonts) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ee79e5b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing to UDC Water Resources Dashboard + +Welcome! This project is developed at UDC's CAUSES / WRRI for water quality research and environmental justice in DC. + +## Getting Started + +### Prerequisites +- **Node.js 20+** (LTS recommended) +- **npm** (comes with Node.js) +- **Git** + +### Setup + +```bash +git clone https://github.com/OliTamrat/UDC.git +cd UDC +npm install +npm run db:seed # Populate the local SQLite database +npm run dev # Start dev server at http://localhost:3000 +``` + +### Useful Commands + +| Command | What it does | +|---------|-------------| +| `npm run dev` | Start development server | +| `npm run build` | Production build | +| `npm test` | Run all tests | +| `npm run test:watch` | Run tests in watch mode | +| `npm run db:seed` | Seed SQLite database from static data | +| `npm run lint` | Run ESLint | + +## Project Structure + +``` +src/ +├── app/ # Next.js App Router pages and API routes +│ ├── api/ # Backend: stations, export, ingest, health +│ └── station/ # Dynamic station detail pages +├── components/ # React components (map, dashboard, charts, layout) +├── context/ # Theme provider +├── data/ # Static geospatial and reference data +├── lib/ # Utilities: database, logger, validation +└── __tests__/ # Vitest test suites +scripts/ # Database seed script +data/ # SQLite database (gitignored) +``` + +## Development Workflow + +1. **Create a branch** from `main`: `git checkout -b feature/your-feature` +2. **Make changes** — follow the coding standards below +3. **Run tests**: `npm test` +4. **Verify build**: `npm run build` +5. **Open a PR** against `main` with a clear description + +## Coding Standards + +- **TypeScript strict mode** — all code must pass `tsc` with no errors +- **Functional React components** with hooks (no class components except ErrorBoundary) +- **Tailwind CSS** for styling — use the UDC theme variables (`udc-gold`, `udc-red`, `udc-navy`) +- **Dark/light theme** — all new components must support both themes using `useTheme()` +- **No `console.log`** in production code — use `src/lib/logger.ts` instead +- **Test new features** — add tests in `src/__tests__/` using Vitest + +## API Routes + +All API routes are in `src/app/api/`. The database connection is in `src/lib/db.ts`. + +| Route | Method | Purpose | +|-------|--------|---------| +| `/api/stations` | GET | List all stations with latest readings | +| `/api/stations/:id/history` | GET | Time-series data for a station | +| `/api/export` | GET | CSV/JSON data export | +| `/api/ingest` | POST | Fetch data from USGS NWIS | +| `/api/health` | GET | Health check | + +## Data Sources + +- **USGS NWIS** — real-time stream gauge data via `waterservices.usgs.gov` +- **DC GIS** — ward boundaries, waterway polygons from DC Open Data +- **Static data** — `src/data/dc-waterways.ts` (reference/seed data) + +## Need Help? + +- Check `CLAUDE.md` for the project memory and architecture plan +- Open an issue on GitHub for bugs or feature requests +- Contact the WRRI lab for data-related questions diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9073e4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS base + +# Install dependencies +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +# Build the application +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run db:seed && npm run build + +# Production runner +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/data ./data + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 2c27e42..be675ba 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,44 @@ The UDC Water Resources Data Dashboard is a centralized platform that brings tog | Mapping | Leaflet | | Charts | Recharts | | Icons | Lucide React | +| Database | SQLite (better-sqlite3) | +| Testing | Vitest | + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React) │ +│ ┌──────────┐ ┌────────────┐ ┌───────────┐ ┌─────────┐ │ +│ │ Dashboard │ │ Station │ │ Research │ │Education│ │ +│ │ (Map, │ │ Detail │ │ Portal │ │& Outreach│ │ +│ │ Charts, │ │ /station/ │ │ /research │ │/education│ │ +│ │ Tables) │ │ [id] │ │ │ │ │ │ +│ └────┬─────┘ └─────┬──────┘ └───────────┘ └─────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ Next.js API Routes │ │ +│ │ GET /api/stations GET /api/stations/:id/history│ │ +│ │ GET /api/export POST /api/ingest │ │ +│ │ GET /api/health │ │ +│ └────────────────────────┬─────────────────────────┘ │ +└───────────────────────────┼──────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ SQLite Database │ + │ stations | readings │ + │ ingestion_log │ + └───────────┬───────────┘ + │ + ┌─────────────▼──────────────┐ + │ External Data Sources │ + │ USGS NWIS | EPA WQX │ + │ DC DOEE | DC GIS │ + └────────────────────────────┘ +``` --- @@ -177,6 +215,56 @@ This dashboard supports and integrates data from: --- +## Deployment + +### Production Build + +```bash +npm run build +npm start # Starts on port 3000 +``` + +### Docker + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:20-alpine AS runner +WORKDIR /app +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +```bash +docker build -t udc-dashboard . +docker run -p 3000:3000 udc-dashboard +``` + +### Vercel + +Push to GitHub and import the repository at [vercel.com/new](https://vercel.com/new). No additional configuration needed. + +### Health Check + +The `/api/health` endpoint returns JSON with `status`, `timestamp`, `version`, and `uptime` — use this for load balancer or uptime monitoring probes. + +### Testing + +```bash +npm test # Run all tests once +npm run test:watch # Watch mode +``` + +--- + ## Funded By DC Government | DC DOEE | USDA NIFA | EPA Region 3 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8fc6dbb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + dashboard: + build: . + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - DB_PATH=/app/data/udc-water.db + volumes: + - db-data:/app/data + restart: unless-stopped + +volumes: + db-data: diff --git a/next.config.ts b/next.config.ts index 68a6c64..a07e922 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,45 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - output: "standalone", + // Ensure Turbopack resolves from the project root, not a parent directory + turbopack: { + root: ".", + }, + // output: "standalone" is for Docker only; Vercel uses its own serverless build + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://vercel.live https://*.vercel.live", + "script-src-elem 'self' 'unsafe-inline' https://vercel.live https://*.vercel.live", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob: https://*.basemaps.cartocdn.com https://unpkg.com https://vercel.live https://*.vercel.live", + "connect-src 'self' https://waterservices.usgs.gov https://www.waterqualitydata.us https://*.basemaps.cartocdn.com https://vercel.live https://*.vercel.live wss://ws-us3.pusher.com", + "frame-src 'self' https://vercel.live https://*.vercel.live", + ].join("; "), + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index e36aeed..2753212 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,23 @@ { - "name": "udc", + "name": "udc-water-dashboard", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "udc", + "name": "udc-water-dashboard", "version": "1.0.0", - "license": "ISC", "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/react": "^3.0.118", + "@neondatabase/serverless": "^1.0.2", "@tailwindcss/postcss": "^4.2.1", "@types/leaflet": "^1.9.21", "@types/node": "^25.4.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "ai": "^6.0.116", + "better-sqlite3": "^12.6.2", "leaflet": "^1.9.4", "lucide-react": "^0.577.0", "next": "^16.1.6", @@ -23,7 +27,113 @@ "react-leaflet": "^5.0.0", "recharts": "^3.8.0", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "ws": "^8.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.13", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", + "tsx": "^4.21.0", + "vitest": "^4.0.18" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ai-sdk/anthropic": { + "version": "3.0.58", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.58.tgz", + "integrity": "sha512-/53SACgmVukO4bkms4dpxpRlYhW8Ct6QZRe6sj1Pi5H00hYhxIrqfiLbZBGxkdRvjsBQeP/4TVGsXgH5rQeb8Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.66", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.66.tgz", + "integrity": "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.19.tgz", + "integrity": "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "3.0.118", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.118.tgz", + "integrity": "sha512-fBAix8Jftxse6/2YJnOFkwW1/O6EQK4DK68M9DlFmZGAzBmsaHXEPVS77sVIlkaOWCy11bE7434NAVXRY+3OsQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "4.0.19", + "ai": "6.0.116", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "node_modules/@alloc/quick-lru": { @@ -38,14 +148,989 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } } }, "node_modules/@img/colour": { @@ -559,6 +1644,34 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.0.2.tgz", + "integrity": "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.15.30", + "@types/pg": "^8.8.0" + }, + "engines": { + "node": ">=19.0.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@neondatabase/serverless/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@next/env": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz", @@ -693,6 +1806,15 @@ "node": ">= 10" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -709,36 +1831,393 @@ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", - "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1017,6 +2496,156 @@ "tailwindcss": "4.2.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1080,6 +2709,20 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -1104,6 +2747,17 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1128,16 +2782,362 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", - "license": "Apache-2.0", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ai": { + "version": "6.0.116", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.116.tgz", + "integrity": "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.66", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.19", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/better-sqlite3": { + "version": "12.6.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", + "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "browserslist": "cli.js" }, "engines": { - "node": ">=6.0.0" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/caniuse-lite": { @@ -1160,6 +3160,22 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -1175,6 +3191,60 @@ "node": ">=6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1302,12 +3372,84 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1317,6 +3459,30 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -1330,6 +3496,26 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -1340,18 +3526,243 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/immer": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", @@ -1362,22 +3773,131 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { - "jiti": "lib/jiti-cli.mjs" + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" } }, "node_modules/leaflet": { @@ -1635,6 +4155,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/lucide-react": { "version": "0.577.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", @@ -1644,6 +4174,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1653,6 +4194,57 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1671,6 +4263,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", @@ -1752,12 +4350,115 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-abi": { + "version": "3.88.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", + "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -1786,6 +4487,131 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -1851,6 +4677,30 @@ } } }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/recharts": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", @@ -1881,6 +4731,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -1896,12 +4760,110 @@ "redux": "^5.0.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1913,7 +4875,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -1966,6 +4927,58 @@ "@img/sharp-win32-x64": "0.34.5" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1975,6 +4988,51 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1998,6 +5056,26 @@ } } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -2017,18 +5095,180 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2042,12 +5282,53 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -2057,6 +5338,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -2078,6 +5365,293 @@ "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 46d4f61..f6a11bb 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,24 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "tsx scripts/seed.ts && next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest", + "db:seed": "tsx scripts/seed.ts" }, "dependencies": { + "@ai-sdk/anthropic": "^3.0.58", + "@ai-sdk/react": "^3.0.118", + "@neondatabase/serverless": "^1.0.2", "@tailwindcss/postcss": "^4.2.1", "@types/leaflet": "^1.9.21", "@types/node": "^25.4.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "ai": "^6.0.116", + "better-sqlite3": "^12.6.2", "leaflet": "^1.9.4", "lucide-react": "^0.577.0", "next": "^16.1.6", @@ -24,6 +32,18 @@ "react-leaflet": "^5.0.0", "recharts": "^3.8.0", "tailwindcss": "^4.2.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "ws": "^8.19.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/better-sqlite3": "^7.6.13", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^5.1.4", + "jsdom": "^28.1.0", + "tsx": "^4.21.0", + "vitest": "^4.0.18" } } diff --git a/public/templates/udc_water_analysis.R b/public/templates/udc_water_analysis.R new file mode 100644 index 0000000..1668e01 --- /dev/null +++ b/public/templates/udc_water_analysis.R @@ -0,0 +1,167 @@ +# ============================================================================ +# UDC Water Resources Data Analysis Template (R) +# ============================================================================ +# +# University of the District of Columbia +# College of Agriculture, Urban Sustainability and Environmental Sciences (CAUSES) +# Water Resources Research Institute (WRRI) +# +# This script fetches water quality data from the UDC Water Dashboard API +# and performs basic analysis. Designed for UDC students and researchers. +# +# Required packages: +# install.packages(c("httr", "jsonlite", "ggplot2", "dplyr", "tidyr")) +# +# Usage: +# source("udc_water_analysis.R") +# +# ============================================================================ + +library(httr) +library(jsonlite) +library(ggplot2) +library(dplyr) +library(tidyr) + +# --------------------------------------------------------------------------- +# Configuration — update BASE_URL to your deployment +# --------------------------------------------------------------------------- +BASE_URL <- "https://udc-water.vercel.app" # Change to your deployment URL +STATION_ID <- "ANA-001" # Change to any station ID + +# --------------------------------------------------------------------------- +# 1. Fetch station list +# --------------------------------------------------------------------------- +cat("Fetching stations...\n") +stations_resp <- GET(paste0(BASE_URL, "/api/stations")) +stations <- fromJSON(content(stations_resp, "text", encoding = "UTF-8")) + +cat(sprintf("Found %d monitoring stations:\n\n", nrow(stations))) +for (i in seq_len(nrow(stations))) { + cat(sprintf(" %-10s %-40s Status: %s\n", + stations$id[i], stations$name[i], stations$status[i])) +} + +# --------------------------------------------------------------------------- +# 2. Fetch historical data for a station +# --------------------------------------------------------------------------- +cat(sprintf("\nFetching historical data for %s...\n", STATION_ID)) +history_resp <- GET(paste0(BASE_URL, "/api/stations/", STATION_ID, "/history")) +history <- fromJSON(content(history_resp, "text", encoding = "UTF-8")) + +df <- as_tibble(history$data) + +if (nrow(df) == 0) { + stop("No historical data available. Run USGS ingestion first.") +} + +df <- df %>% + mutate(timestamp = as.POSIXct(timestamp, format = "%Y-%m-%dT%H:%M:%S")) %>% + arrange(timestamp) + +cat(sprintf("Loaded %d readings from %s to %s\n", + nrow(df), + format(min(df$timestamp, na.rm = TRUE)), + format(max(df$timestamp, na.rm = TRUE)))) +cat(sprintf("Data sources: %s\n", paste(unique(df$source), collapse = ", "))) + +# --------------------------------------------------------------------------- +# 3. Summary statistics +# --------------------------------------------------------------------------- +cat("\n", strrep("=", 60), "\n") +cat(sprintf("Summary Statistics for %s\n", STATION_ID)) +cat(strrep("=", 60), "\n") + +params <- c("dissolvedOxygen", "temperature", "pH", "turbidity", "eColiCount") +for (param in params) { + if (param %in% names(df)) { + values <- df[[param]] + values <- values[!is.na(values)] + if (length(values) > 0) { + cat(sprintf("\n %s:\n", param)) + cat(sprintf(" Mean: %.2f\n", mean(values))) + cat(sprintf(" Median: %.2f\n", median(values))) + cat(sprintf(" Min: %.2f\n", min(values))) + cat(sprintf(" Max: %.2f\n", max(values))) + cat(sprintf(" SD: %.2f\n", sd(values))) + } + } +} + +# --------------------------------------------------------------------------- +# 4. EPA compliance check +# --------------------------------------------------------------------------- +cat("\n", strrep("=", 60), "\n") +cat("EPA Compliance Check\n") +cat(strrep("=", 60), "\n") + +if ("dissolvedOxygen" %in% names(df)) { + do_data <- df$dissolvedOxygen[!is.na(df$dissolvedOxygen)] + do_violations <- sum(do_data < 5.0) + cat(sprintf("\n Dissolved Oxygen < 5.0 mg/L: %d/%d readings (%.1f%% non-compliant)\n", + do_violations, length(do_data), + 100 * do_violations / length(do_data))) +} + +if ("eColiCount" %in% names(df)) { + ecoli_data <- df$eColiCount[!is.na(df$eColiCount)] + ecoli_violations <- sum(ecoli_data > 410) + cat(sprintf(" E. coli > 410 CFU/100mL: %d/%d readings (%.1f%% non-compliant)\n", + ecoli_violations, length(ecoli_data), + 100 * ecoli_violations / length(ecoli_data))) +} + +# --------------------------------------------------------------------------- +# 5. Visualization +# --------------------------------------------------------------------------- + +# Dissolved Oxygen trend with EPA threshold +if ("dissolvedOxygen" %in% names(df)) { + p1 <- ggplot(df, aes(x = timestamp, y = dissolvedOxygen)) + + geom_line(color = "#3B82F6", linewidth = 0.8) + + geom_hline(yintercept = 5.0, linetype = "dashed", color = "#EF4444") + + annotate("text", x = min(df$timestamp, na.rm = TRUE), y = 5.2, + label = "EPA Min (5 mg/L)", color = "#EF4444", hjust = 0, size = 3) + + labs(title = sprintf("Dissolved Oxygen — %s", STATION_ID), + x = "Date", y = "DO (mg/L)") + + theme_minimal() + + theme(plot.title = element_text(face = "bold")) + + ggsave(sprintf("udc_do_%s.png", STATION_ID), p1, width = 10, height = 5, dpi = 150) + cat(sprintf("\nChart saved: udc_do_%s.png\n", STATION_ID)) +} + +# Multi-parameter comparison +df_long <- df %>% + select(timestamp, dissolvedOxygen, temperature, pH, turbidity) %>% + pivot_longer(cols = -timestamp, names_to = "parameter", values_to = "value") %>% + filter(!is.na(value)) + +if (nrow(df_long) > 0) { + p2 <- ggplot(df_long, aes(x = timestamp, y = value, color = parameter)) + + geom_line(linewidth = 0.6) + + facet_wrap(~parameter, scales = "free_y", ncol = 2) + + labs(title = sprintf("Water Quality Parameters — %s", STATION_ID), + x = "Date", y = "Value") + + theme_minimal() + + theme(plot.title = element_text(face = "bold"), + legend.position = "none") + + ggsave(sprintf("udc_multi_%s.png", STATION_ID), p2, width = 12, height = 8, dpi = 150) + cat(sprintf("Chart saved: udc_multi_%s.png\n", STATION_ID)) +} + +# --------------------------------------------------------------------------- +# 6. Export for further analysis +# --------------------------------------------------------------------------- +csv_filename <- sprintf("udc_water_%s_%s.csv", STATION_ID, format(Sys.Date(), "%Y%m%d")) +write.csv(df, csv_filename, row.names = FALSE) +cat(sprintf("Data exported: %s\n", csv_filename)) + +cat("\n", strrep("=", 60), "\n") +cat("Citation:\n") +cat(" UDC Water Resources Research Institute. (2026).\n") +cat(sprintf(" Station %s Water Quality Data [Dataset].\n", STATION_ID)) +cat(" University of the District of Columbia CAUSES.\n") +cat(sprintf(" Retrieved %s from %s/api/export\n", format(Sys.Date()), BASE_URL)) +cat(strrep("=", 60), "\n") diff --git a/public/templates/udc_water_analysis.py b/public/templates/udc_water_analysis.py new file mode 100644 index 0000000..5b6b66a --- /dev/null +++ b/public/templates/udc_water_analysis.py @@ -0,0 +1,160 @@ +""" +UDC Water Resources Data Analysis Template +========================================== + +University of the District of Columbia +College of Agriculture, Urban Sustainability and Environmental Sciences (CAUSES) +Water Resources Research Institute (WRRI) + +This script fetches water quality data from the UDC Water Dashboard API +and performs basic analysis. Designed for UDC students and researchers. + +Usage: + pip install requests pandas matplotlib + python udc_water_analysis.py + +API Base URL: Update BASE_URL below to match your deployment. +""" + +import requests +import pandas as pd +import matplotlib.pyplot as plt +from datetime import datetime + +# --------------------------------------------------------------------------- +# Configuration — update BASE_URL to your deployment +# --------------------------------------------------------------------------- +BASE_URL = "https://udc-water.vercel.app" # Change to your deployment URL + +# --------------------------------------------------------------------------- +# 1. Fetch station list +# --------------------------------------------------------------------------- +print("Fetching stations...") +stations_resp = requests.get(f"{BASE_URL}/api/stations") +stations_resp.raise_for_status() +stations = stations_resp.json() + +print(f"Found {len(stations)} monitoring stations:\n") +for s in stations: + reading = s.get("lastReading", {}) + source = reading.get("source", "N/A") if reading else "N/A" + print(f" {s['id']:10s} {s['name']:40s} Status: {s['status']:12s} Source: {source}") + +# --------------------------------------------------------------------------- +# 2. Fetch historical data for a station +# --------------------------------------------------------------------------- +STATION_ID = "ANA-001" # Change to any station ID from the list above + +print(f"\nFetching historical data for {STATION_ID}...") +history_resp = requests.get(f"{BASE_URL}/api/stations/{STATION_ID}/history") +history_resp.raise_for_status() +history = history_resp.json() + +df = pd.DataFrame(history["data"]) +if not df.empty: + df["timestamp"] = pd.to_datetime(df["timestamp"]) + df = df.sort_values("timestamp") + print(f"Loaded {len(df)} readings from {df['timestamp'].min()} to {df['timestamp'].max()}") + print(f"Data sources: {df['source'].unique().tolist()}") +else: + print("No historical data available. Run USGS ingestion first.") + exit() + +# --------------------------------------------------------------------------- +# 3. Summary statistics +# --------------------------------------------------------------------------- +print(f"\n{'='*60}") +print(f"Summary Statistics for {STATION_ID}") +print(f"{'='*60}") + +params = ["dissolvedOxygen", "temperature", "pH", "turbidity", "eColiCount"] +for param in params: + if param in df.columns and df[param].notna().any(): + series = df[param].dropna() + print(f"\n {param}:") + print(f" Mean: {series.mean():.2f}") + print(f" Median: {series.median():.2f}") + print(f" Min: {series.min():.2f}") + print(f" Max: {series.max():.2f}") + print(f" Std: {series.std():.2f}") + +# --------------------------------------------------------------------------- +# 4. EPA compliance check +# --------------------------------------------------------------------------- +print(f"\n{'='*60}") +print("EPA Compliance Check") +print(f"{'='*60}") + +if "dissolvedOxygen" in df.columns: + do_violations = df[df["dissolvedOxygen"] < 5.0] + total_do = df["dissolvedOxygen"].notna().sum() + print(f"\n Dissolved Oxygen < 5.0 mg/L: {len(do_violations)}/{total_do} readings " + f"({100*len(do_violations)/total_do:.1f}% non-compliant)" if total_do > 0 else " No DO data") + +if "eColiCount" in df.columns: + ecoli_violations = df[df["eColiCount"] > 410] + total_ecoli = df["eColiCount"].notna().sum() + print(f" E. coli > 410 CFU/100mL: {len(ecoli_violations)}/{total_ecoli} readings " + f"({100*len(ecoli_violations)/total_ecoli:.1f}% non-compliant)" if total_ecoli > 0 else " No E. coli data") + +# --------------------------------------------------------------------------- +# 5. Visualization +# --------------------------------------------------------------------------- +fig, axes = plt.subplots(2, 2, figsize=(14, 10)) +fig.suptitle(f"UDC Water Quality — Station {STATION_ID}", fontsize=14, fontweight="bold") + +# Dissolved Oxygen +if "dissolvedOxygen" in df.columns: + ax = axes[0, 0] + ax.plot(df["timestamp"], df["dissolvedOxygen"], color="#3B82F6", linewidth=1.5) + ax.axhline(y=5.0, color="#EF4444", linestyle="--", linewidth=1, label="EPA Min (5 mg/L)") + ax.set_ylabel("DO (mg/L)") + ax.set_title("Dissolved Oxygen") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + +# Temperature +if "temperature" in df.columns: + ax = axes[0, 1] + ax.plot(df["timestamp"], df["temperature"], color="#22D3EE", linewidth=1.5) + ax.set_ylabel("Temperature (°C)") + ax.set_title("Water Temperature") + ax.grid(alpha=0.3) + +# E. coli +if "eColiCount" in df.columns: + ax = axes[1, 0] + ax.bar(df["timestamp"], df["eColiCount"], color="#EF4444", alpha=0.7, width=20) + ax.axhline(y=410, color="#F59E0B", linestyle="--", linewidth=1, label="EPA Rec. Limit (410)") + ax.set_ylabel("E. coli (CFU/100mL)") + ax.set_title("E. coli Levels") + ax.legend(fontsize=8) + ax.grid(alpha=0.3) + +# Turbidity +if "turbidity" in df.columns: + ax = axes[1, 1] + ax.plot(df["timestamp"], df["turbidity"], color="#F59E0B", linewidth=1.5) + ax.set_ylabel("Turbidity (NTU)") + ax.set_title("Turbidity") + ax.grid(alpha=0.3) + +plt.tight_layout() +plt.savefig(f"udc_water_{STATION_ID}.png", dpi=150, bbox_inches="tight") +print(f"\nChart saved: udc_water_{STATION_ID}.png") +plt.show() + +# --------------------------------------------------------------------------- +# 6. Export for further analysis +# --------------------------------------------------------------------------- +csv_filename = f"udc_water_{STATION_ID}_{datetime.now().strftime('%Y%m%d')}.csv" +df.to_csv(csv_filename, index=False) +print(f"Data exported: {csv_filename}") + +print(f"\n{'='*60}") +print("Citation:") +print(" UDC Water Resources Research Institute. (2026).") +print(f" Station {STATION_ID} Water Quality Data [Dataset].") +print(" University of the District of Columbia CAUSES.") +print(f" Retrieved {datetime.now().strftime('%Y-%m-%d')} from {BASE_URL}/api/export") +print(f"{'='*60}") diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..61e2f21 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,241 @@ +/** + * Seed script — populates the database with existing static data. + * Supports both SQLite (local) and PostgreSQL (Neon). + * + * Usage: + * Local SQLite: npx tsx scripts/seed.ts + * Neon PG: DATABASE_URL=postgresql://... npx tsx scripts/seed.ts + */ +import { monitoringStations, getStationHistoricalData } from "../src/data/dc-waterways"; + +// --------------------------------------------------------------------------- +// Database abstraction (mirrors src/lib/db.ts but standalone for scripts) +// --------------------------------------------------------------------------- +interface SeedDb { + execute(sql: string): Promise; + query(sql: string, params?: unknown[]): Promise; + close(): Promise; +} + +async function createSeedDb(): Promise { + const databaseUrl = process.env.DATABASE_URL; + + if (databaseUrl) { + // PostgreSQL via Neon — needs WebSocket in Node.js + const { Pool, neonConfig } = await import("@neondatabase/serverless"); + const ws = await import("ws"); + neonConfig.webSocketConstructor = ws.default; + const pool = new Pool({ connectionString: databaseUrl }); + + return { + async execute(statements: string) { + const parts = statements.split(";").map(s => s.trim()).filter(Boolean); + for (const part of parts) { + await pool.query(part); + } + }, + async query(query: string, params: unknown[] = []) { + let idx = 0; + const pgQuery = query.replace(/\?/g, () => `$${++idx}`); + await pool.query(pgQuery, params); + }, + async close() { await pool.end(); }, + }; + } else { + // SQLite via better-sqlite3 + const Database = (await import("better-sqlite3")).default; + const path = await import("path"); + const fs = await import("fs"); + + const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "udc-water.db"); + const dir = path.dirname(DB_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const db = new Database(DB_PATH); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + + return { + async execute(statements: string) { + db.exec(statements); + }, + async query(sql: string, params: unknown[] = []) { + db.prepare(sql).run(...params); + }, + async close() { + db.close(); + }, + }; + } +} + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- +const isPostgres = !!process.env.DATABASE_URL; + +const SCHEMA = isPostgres + ? ` + CREATE TABLE IF NOT EXISTS stations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + type TEXT NOT NULL CHECK(type IN ('river', 'stream', 'stormwater', 'green-infrastructure')), + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'maintenance', 'offline')), + parameters TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS readings ( + id SERIAL PRIMARY KEY, + station_id TEXT NOT NULL REFERENCES stations(id), + timestamp TIMESTAMPTZ NOT NULL, + temperature DOUBLE PRECISION, + dissolved_oxygen DOUBLE PRECISION, + ph DOUBLE PRECISION, + turbidity DOUBLE PRECISION, + conductivity DOUBLE PRECISION, + ecoli_count DOUBLE PRECISION, + nitrate_n DOUBLE PRECISION, + phosphorus DOUBLE PRECISION, + source TEXT DEFAULT 'manual', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_readings_station_time + ON readings(station_id, timestamp DESC); + + CREATE INDEX IF NOT EXISTS idx_readings_timestamp + ON readings(timestamp DESC); + + CREATE TABLE IF NOT EXISTS ingestion_log ( + id SERIAL PRIMARY KEY, + source TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('success', 'error')), + records_count INTEGER DEFAULT 0, + error_message TEXT, + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ + ) +` + : ` + CREATE TABLE IF NOT EXISTS stations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + type TEXT NOT NULL CHECK(type IN ('river', 'stream', 'stormwater', 'green-infrastructure')), + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'maintenance', 'offline')), + parameters TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + station_id TEXT NOT NULL REFERENCES stations(id), + timestamp TEXT NOT NULL, + temperature REAL, + dissolved_oxygen REAL, + ph REAL, + turbidity REAL, + conductivity REAL, + ecoli_count REAL, + nitrate_n REAL, + phosphorus REAL, + source TEXT DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_readings_station_time + ON readings(station_id, timestamp DESC); + + CREATE INDEX IF NOT EXISTS idx_readings_timestamp + ON readings(timestamp DESC); + + CREATE TABLE IF NOT EXISTS ingestion_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('success', 'error')), + records_count INTEGER DEFAULT 0, + error_message TEXT, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + completed_at TEXT + ) +`; + +// --------------------------------------------------------------------------- +// Seed +// --------------------------------------------------------------------------- +async function seed() { + const db = await createSeedDb(); + const provider = isPostgres ? "PostgreSQL (Neon)" : "SQLite"; + console.log(`Seeding ${provider} database...`); + + // Create schema + await db.execute(SCHEMA); + + // Clear existing seed data + await db.query("DELETE FROM readings WHERE source = 'seed'"); + + let stationCount = 0; + let readingCount = 0; + + const upsertStationSQL = isPostgres + ? `INSERT INTO stations (id, name, latitude, longitude, type, status, parameters) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, latitude = EXCLUDED.latitude, + longitude = EXCLUDED.longitude, type = EXCLUDED.type, status = EXCLUDED.status, + parameters = EXCLUDED.parameters, updated_at = NOW()` + : `INSERT OR REPLACE INTO stations (id, name, latitude, longitude, type, status, parameters) + VALUES (?, ?, ?, ?, ?, ?, ?)`; + + const insertReadingSQL = isPostgres + ? `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, ecoli_count, source) + VALUES (?, ?, ?, ?, ?, ?, ?, 'seed')` + : `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, ecoli_count, source) + VALUES (?, ?, ?, ?, ?, ?, ?, 'seed')`; + + for (const station of monitoringStations) { + await db.query(upsertStationSQL, [ + station.id, + station.name, + station.position[0], + station.position[1], + station.type, + station.status, + JSON.stringify(station.parameters), + ]); + stationCount++; + + // Seed historical data + const historical = getStationHistoricalData(station.id); + if (historical) { + for (const reading of historical.data) { + const monthIndex = historical.months.indexOf(reading.month); + const timestamp = `2025-${String(monthIndex + 1).padStart(2, "0")}-15T12:00:00Z`; + await db.query(insertReadingSQL, [ + station.id, + timestamp, + reading.temperature, + reading.dissolvedOxygen, + reading.pH, + reading.turbidity, + reading.eColiCount, + ]); + readingCount++; + } + } + } + + console.log(`Seeded ${stationCount} stations and ${readingCount} readings to ${provider}`); + await db.close(); +} + +seed().catch((err) => { + console.error("Seed failed:", err); + process.exit(1); +}); diff --git a/src/__tests__/api-export.test.ts b/src/__tests__/api-export.test.ts new file mode 100644 index 0000000..8e06191 --- /dev/null +++ b/src/__tests__/api-export.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "@/app/api/export/route"; +import { getDb } from "@/lib/db"; + +beforeAll(() => { + const db = getDb(); + const count = (db.prepare("SELECT COUNT(*) as c FROM readings").get() as { c: number }).c; + if (count === 0) { + throw new Error("Database not seeded. Run `npm run db:seed` first."); + } +}); + +function makeRequest(url: string) { + return new NextRequest(new URL(url, "http://localhost:3000")); +} + +describe("GET /api/export", () => { + it("returns JSON export with all readings and citation", async () => { + const request = makeRequest("/api/export"); + const response = await GET(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.exported_at).toBeTruthy(); + expect(body.count).toBe(144); // 12 stations * 12 months + expect(body.data).toHaveLength(144); + // Citation metadata + expect(body.citation).toBeTruthy(); + expect(body.citation.text).toContain("UDC Water Resources Research Institute"); + expect(body.citation.publisher).toContain("CAUSES"); + expect(body.sources).toBeInstanceOf(Array); + }); + + it("filters by station ID", async () => { + const request = makeRequest("/api/export?station=ANA-001"); + const response = await GET(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.count).toBe(12); + expect(body.citation.text).toContain("ANA-001"); + for (const row of body.data) { + expect(row.station_id).toBe("ANA-001"); + } + }); + + it("returns CSV format with citation header and correct columns", async () => { + const request = makeRequest("/api/export?format=csv&station=ANA-001"); + const response = await GET(request); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/csv"); + expect(response.headers.get("Content-Disposition")).toContain("udc-water-data-ANA-001.csv"); + + const csv = await response.text(); + const lines = csv.split("\n"); + // Citation lines start with #, then header row, then data rows + const citationLines = lines.filter((l) => l.startsWith("#")); + const dataLines = lines.filter((l) => !l.startsWith("#") && l.trim()); + expect(citationLines.length).toBeGreaterThanOrEqual(5); + expect(citationLines[0]).toContain("Citation:"); + // Header + 12 data rows + expect(dataLines.length).toBe(13); + expect(dataLines[0]).toContain("station_id"); + expect(dataLines[0]).toContain("dissolved_oxygen"); + expect(dataLines[1]).toContain("ANA-001"); + }); + + it("returns empty data for nonexistent station", async () => { + const request = makeRequest("/api/export?station=FAKE-999"); + const response = await GET(request); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.count).toBe(0); + expect(body.data).toHaveLength(0); + }); +}); diff --git a/src/__tests__/api-history.test.ts b/src/__tests__/api-history.test.ts new file mode 100644 index 0000000..395624d --- /dev/null +++ b/src/__tests__/api-history.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "@/app/api/stations/[id]/history/route"; +import { getDb } from "@/lib/db"; + +beforeAll(() => { + const db = getDb(); + const count = (db.prepare("SELECT COUNT(*) as c FROM readings").get() as { c: number }).c; + if (count === 0) { + throw new Error("Database not seeded. Run `npm run db:seed` first."); + } +}); + +function makeRequest(url: string) { + return new NextRequest(new URL(url, "http://localhost:3000")); +} + +describe("GET /api/stations/:id/history", () => { + it("returns 12 months of data for ANA-001", async () => { + const request = makeRequest("/api/stations/ANA-001/history"); + const response = await GET(request, { params: Promise.resolve({ id: "ANA-001" }) }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.stationId).toBe("ANA-001"); + expect(body.count).toBe(12); + expect(body.data).toHaveLength(12); + }); + + it("returns readings with correct fields", async () => { + const request = makeRequest("/api/stations/ANA-001/history"); + const response = await GET(request, { params: Promise.resolve({ id: "ANA-001" }) }); + const body = await response.json(); + + const reading = body.data[0]; + expect(reading.timestamp).toBeTruthy(); + expect(typeof reading.temperature).toBe("number"); + expect(typeof reading.dissolvedOxygen).toBe("number"); + expect(typeof reading.pH).toBe("number"); + expect(typeof reading.turbidity).toBe("number"); + expect(typeof reading.eColiCount).toBe("number"); + expect(reading.source).toBe("seed"); + }); + + it("returns 404 for nonexistent station", async () => { + const request = makeRequest("/api/stations/FAKE-999/history"); + const response = await GET(request, { params: Promise.resolve({ id: "FAKE-999" }) }); + + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe("Station not found"); + }); + + it("respects date range filters", async () => { + const request = makeRequest("/api/stations/ANA-001/history?from=2025-06-01&to=2025-08-31"); + const response = await GET(request, { params: Promise.resolve({ id: "ANA-001" }) }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.count).toBe(3); // Jun, Jul, Aug + for (const reading of body.data) { + expect(reading.timestamp >= "2025-06-01").toBe(true); + expect(reading.timestamp <= "2025-08-31T23:59:59").toBe(true); + } + }); + + it("respects limit parameter", async () => { + const request = makeRequest("/api/stations/ANA-001/history?limit=3"); + const response = await GET(request, { params: Promise.resolve({ id: "ANA-001" }) }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.count).toBe(3); + }); +}); diff --git a/src/__tests__/api-stations.test.ts b/src/__tests__/api-stations.test.ts new file mode 100644 index 0000000..38eaec6 --- /dev/null +++ b/src/__tests__/api-stations.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { GET } from "@/app/api/stations/route"; +import { getDb } from "@/lib/db"; + +// Ensure DB is seeded before tests +beforeAll(() => { + const db = getDb(); + const count = (db.prepare("SELECT COUNT(*) as c FROM stations").get() as { c: number }).c; + if (count === 0) { + throw new Error("Database not seeded. Run `npm run db:seed` first."); + } +}); + +describe("GET /api/stations", () => { + it("returns all 12 stations", async () => { + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(12); + }); + + it("each station has required fields", async () => { + const response = await GET(); + const data = await response.json(); + + for (const station of data) { + expect(station.id).toBeTruthy(); + expect(station.name).toBeTruthy(); + expect(station.position).toHaveLength(2); + expect(typeof station.position[0]).toBe("number"); + expect(typeof station.position[1]).toBe("number"); + expect(["river", "stream", "stormwater", "green-infrastructure"]).toContain(station.type); + expect(["active", "maintenance", "offline"]).toContain(station.status); + expect(Array.isArray(station.parameters)).toBe(true); + } + }); + + it("includes last reading data for seeded stations", async () => { + const response = await GET(); + const data = await response.json(); + + const withReadings = data.filter((s: Record) => s.lastReading); + expect(withReadings.length).toBeGreaterThan(0); + + const reading = withReadings[0].lastReading; + expect(reading.timestamp).toBeTruthy(); + expect(typeof reading.temperature).toBe("number"); + expect(typeof reading.dissolvedOxygen).toBe("number"); + expect(typeof reading.pH).toBe("number"); + }); +}); diff --git a/src/__tests__/data.test.ts b/src/__tests__/data.test.ts new file mode 100644 index 0000000..4c20147 --- /dev/null +++ b/src/__tests__/data.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { + monitoringStations, + anacostiaRiver, + dcStreams, + getStationHistoricalData, +} from "@/data/dc-waterways"; + +describe("dc-waterways data", () => { + it("has 12 monitoring stations", () => { + expect(monitoringStations).toHaveLength(12); + }); + + it("all stations have required fields", () => { + for (const station of monitoringStations) { + expect(station.id).toBeTruthy(); + expect(station.name).toBeTruthy(); + expect(station.position).toHaveLength(2); + expect(["river", "stream", "stormwater", "green-infrastructure"]).toContain(station.type); + expect(["active", "maintenance", "offline"]).toContain(station.status); + expect(station.parameters.length).toBeGreaterThan(0); + } + }); + + it("anacostia river has valid coordinates", () => { + expect(anacostiaRiver.coordinates.length).toBeGreaterThan(10); + for (const [lat, lng] of anacostiaRiver.coordinates) { + expect(lat).toBeGreaterThan(38.8); + expect(lat).toBeLessThan(39.0); + expect(lng).toBeGreaterThan(-77.1); + expect(lng).toBeLessThan(-76.9); + } + }); + + it("dc streams are defined", () => { + expect(dcStreams.length).toBeGreaterThan(0); + for (const stream of dcStreams) { + expect(stream.name).toBeTruthy(); + expect(stream.coordinates.length).toBeGreaterThan(0); + } + }); + + it("generates historical data for a valid station", () => { + const station = monitoringStations[0]; + const result = getStationHistoricalData(station.id); + expect(result).not.toBeNull(); + expect(result!.months).toHaveLength(12); + expect(result!.data).toHaveLength(12); + for (const reading of result!.data) { + expect(reading.month).toBeTruthy(); + expect(reading.dissolvedOxygen).toBeGreaterThan(0); + expect(reading.pH).toBeGreaterThan(0); + expect(reading.temperature).toBeDefined(); + } + }); + + it("returns null for unknown station", () => { + const result = getStationHistoricalData("nonexistent"); + expect(result).toBeNull(); + }); +}); diff --git a/src/__tests__/error-boundary.test.tsx b/src/__tests__/error-boundary.test.tsx new file mode 100644 index 0000000..f0d72df --- /dev/null +++ b/src/__tests__/error-boundary.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ErrorBoundary from "@/components/ErrorBoundary"; + +function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) { + throw new Error("Test error"); + } + return
Content rendered
; +} + +describe("ErrorBoundary", () => { + const originalError = console.error; + beforeAll(() => { + console.error = vi.fn(); + }); + afterAll(() => { + console.error = originalError; + }); + + it("renders children when there is no error", () => { + render( + + + + ); + expect(screen.getByText("Content rendered")).toBeInTheDocument(); + }); + + it("renders error UI when child throws", () => { + render( + + + + ); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByText("Test error")).toBeInTheDocument(); + expect(screen.getByText("Try Again")).toBeInTheDocument(); + }); + + it("renders custom fallback when provided", () => { + render( + Custom fallback}> + + + ); + expect(screen.getByText("Custom fallback")).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/health.test.ts b/src/__tests__/health.test.ts new file mode 100644 index 0000000..c6d10d2 --- /dev/null +++ b/src/__tests__/health.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { GET } from "@/app/api/health/route"; + +describe("/api/health", () => { + it("returns healthy status with expected fields", async () => { + const response = await GET(); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.status).toBe("healthy"); + expect(data.timestamp).toBeDefined(); + expect(data.version).toBeDefined(); + expect(typeof data.uptime).toBe("number"); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/src/__tests__/validation.test.ts b/src/__tests__/validation.test.ts new file mode 100644 index 0000000..fad254b --- /dev/null +++ b/src/__tests__/validation.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeSearchInput, isInputSafe } from "@/lib/validation"; + +describe("sanitizeSearchInput", () => { + it("trims whitespace", () => { + expect(sanitizeSearchInput(" hello ")).toBe("hello"); + }); + + it("truncates to 200 characters", () => { + const long = "a".repeat(300); + expect(sanitizeSearchInput(long).length).toBe(200); + }); + + it("escapes HTML entities", () => { + expect(sanitizeSearchInput("bold")).toBe("<b>bold</b>"); + }); +}); + +describe("isInputSafe", () => { + it("allows normal search terms", () => { + expect(isInputSafe("anacostia river")).toBe(true); + expect(isInputSafe("ANA-001")).toBe(true); + expect(isInputSafe("green infrastructure")).toBe(true); + }); + + it("rejects script injection", () => { + expect(isInputSafe('')).toBe(false); + }); + + it("rejects javascript: URIs", () => { + expect(isInputSafe("javascript:alert(1)")).toBe(false); + }); + + it("rejects event handler injection", () => { + expect(isInputSafe('onerror=alert(1)')).toBe(false); + }); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..8fa6285 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,1274 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { useTheme } from "@/context/ThemeContext"; +import { + Upload, + Database, + MapPin, + Activity, + Trash2, + Plus, + RefreshCw, + FileText, + CheckCircle2, + AlertTriangle, + XCircle, + ChevronLeft, + ChevronRight, + Shield, + Edit3, + Save, + X, + Clock, + Download, + Sparkles, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +interface Station { + id: string; + name: string; + latitude: number; + longitude: number; + type: string; + status: string; + parameters: string; + reading_count: number; + last_reading: string | null; +} + +interface Reading { + id: number; + station_id: string; + station_name: string; + timestamp: string; + temperature: number | null; + dissolved_oxygen: number | null; + ph: number | null; + turbidity: number | null; + conductivity: number | null; + ecoli_count: number | null; + nitrate_n: number | null; + phosphorus: number | null; + source: string; +} + +interface IngestionLog { + id: number; + source: string; + status: string; + records_count: number; + error_message: string | null; + started_at: string; + completed_at: string | null; +} + +interface UploadResult { + type: string; + inserted: number; + total_rows: number; + column_mapping: Record; + unmapped_columns: string[]; + warnings: string[]; + errors: string[]; +} + +type Tab = "stations" | "readings" | "upload" | "logs"; + +// --------------------------------------------------------------------------- +// Helper: Auth headers +// --------------------------------------------------------------------------- +function authHeaders(adminKey: string): Record { + const headers: Record = { "Content-Type": "application/json" }; + if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`; + return headers; +} + +function authHeadersNoBody(adminKey: string): Record { + const headers: Record = {}; + if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`; + return headers; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- +export default function AdminPage() { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const [activeTab, setActiveTab] = useState("upload"); + const [adminKey, setAdminKey] = useState(""); + const [authenticated, setAuthenticated] = useState(false); + const [loginError, setLoginError] = useState(""); + const [authChecked, setAuthChecked] = useState(false); + + // Login check + const handleLogin = useCallback(async () => { + setLoginError(""); + try { + const res = await fetch("/api/admin/stations", { + headers: authHeadersNoBody(adminKey), + }); + if (res.ok) { + sessionStorage.setItem("udc-admin-key", adminKey); + setAuthenticated(true); + } else if (res.status === 503) { + setLoginError("Admin access requires ADMIN_API_KEY to be configured on the server."); + } else { + setLoginError("Invalid admin key. Please check your credentials."); + } + } catch { + setLoginError("Unable to connect to the server."); + } + }, [adminKey]); + + // On mount: try stored key from sessionStorage, then try no-auth (dev mode) + useEffect(() => { + const storedKey = sessionStorage.getItem("udc-admin-key") || ""; + if (storedKey) setAdminKey(storedKey); + + fetch("/api/admin/stations", { + headers: storedKey ? { Authorization: `Bearer ${storedKey}` } : {}, + }) + .then((r) => { + if (r.ok) setAuthenticated(true); + setAuthChecked(true); + }) + .catch(() => { + setAuthChecked(true); + }); + }, []); + + if (!authenticated) { + if (!authChecked) { + return ( +
+
Checking authentication...
+
+ ); + } + return ( +
+
+
+
+ +
+
+

Admin Panel

+

UDC Water Resources Data Management

+
+
+ + setAdminKey(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLogin()} + placeholder="Enter ADMIN_API_KEY..." + className={`w-full px-3 py-2 rounded-lg border text-sm mb-3 ${ + isDark + ? "bg-udc-dark border-panel-border text-slate-300 placeholder:text-slate-600" + : "bg-slate-50 border-slate-200 text-slate-700" + }`} + /> + {loginError && ( +
+ + {loginError} +
+ )} + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+ + ← Dashboard + +
+
+
+ UDC +
+
+

Data Management

+

WRRI Faculty Admin Panel

+
+
+
+ +
+
+ + {/* Tabs */} +
+
+ {([ + { id: "upload" as Tab, label: "Upload Data", icon: Upload }, + { id: "stations" as Tab, label: "Stations", icon: MapPin }, + { id: "readings" as Tab, label: "Readings", icon: Activity }, + { id: "logs" as Tab, label: "Ingestion Log", icon: Clock }, + ]).map((tab) => ( + + ))} +
+
+ + {/* Content */} +
+ {activeTab === "upload" && } + {activeTab === "stations" && } + {activeTab === "readings" && } + {activeTab === "logs" && } +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Upload Tab +// --------------------------------------------------------------------------- +interface ColumnMapping { + mapping: Record; + method: string; + model?: string; +} + +interface PreviewData { + file: File; + columns: string[]; + sampleRows: Record[]; + aiMapping: ColumnMapping | null; + loadingAI: boolean; +} + +function UploadTab({ isDark, adminKey }: { isDark: boolean; adminKey: string }) { + const [uploadType, setUploadType] = useState<"readings" | "stations">("readings"); + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [preview, setPreview] = useState(null); + const [customMapping, setCustomMapping] = useState>({}); + const fileRef = useRef(null); + + // Parse file and get AI column mapping + const handleFileSelect = useCallback(async (file: File) => { + setResult(null); + const text = await file.text(); + const fileName = file.name.toLowerCase(); + let columns: string[] = []; + let sampleRows: Record[] = []; + + if (fileName.endsWith(".json")) { + const json = JSON.parse(text); + const data = Array.isArray(json) ? json : json.data || json.readings || json.stations || [json]; + columns = data.length > 0 ? Object.keys(data[0]) : []; + sampleRows = data.slice(0, 5).map((row: Record) => { + const r: Record = {}; + for (const [k, v] of Object.entries(row)) r[k] = v == null ? "" : String(v); + return r; + }); + } else { + const lines = text.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#")); + if (lines.length >= 1) { + columns = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, "")); + for (let i = 1; i < Math.min(lines.length, 6); i++) { + const vals = lines[i].split(",").map((v) => v.trim().replace(/^"|"$/g, "")); + const row: Record = {}; + columns.forEach((h, j) => { row[h] = vals[j] || ""; }); + sampleRows.push(row); + } + } + } + + const previewState: PreviewData = { file, columns, sampleRows, aiMapping: null, loadingAI: true }; + setPreview(previewState); + + // Request AI column mapping + try { + const headers: Record = { "Content-Type": "application/json" }; + if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`; + + const res = await fetch("/api/admin/ai-map-columns", { + method: "POST", + headers, + body: JSON.stringify({ columns, sampleRows: sampleRows.slice(0, 3), dataType: uploadType }), + }); + const aiResult = await res.json(); + setPreview((prev) => prev ? { ...prev, aiMapping: aiResult, loadingAI: false } : null); + setCustomMapping(aiResult.mapping || {}); + } catch { + setPreview((prev) => prev ? { ...prev, loadingAI: false } : null); + } + }, [uploadType, adminKey]); + + const handleConfirmUpload = useCallback(async () => { + if (!preview) return; + setUploading(true); + setResult(null); + + const formData = new FormData(); + formData.append("file", preview.file); + formData.append("type", uploadType); + + try { + const headers: Record = {}; + if (adminKey) headers["Authorization"] = `Bearer ${adminKey}`; + + const res = await fetch("/api/admin/upload", { + method: "POST", + headers, + body: formData, + }); + + const data = await res.json(); + setResult(data); + setPreview(null); + } catch (err) { + setResult({ + type: uploadType, + inserted: 0, + total_rows: 0, + column_mapping: {}, + unmapped_columns: [], + warnings: [], + errors: [err instanceof Error ? err.message : "Upload failed"], + }); + } finally { + setUploading(false); + } + }, [preview, uploadType, adminKey]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) handleFileSelect(file); + }, [handleFileSelect]); + + return ( +
+
+

Upload Data

+

+ Upload CSV or JSON files with water quality readings or station data. + Column names are automatically mapped to the database schema. +

+
+ + {/* Upload type toggle */} +
+ {(["readings", "stations"] as const).map((type) => ( + + ))} +
+ + {/* Drop zone */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileRef.current?.click()} + className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${ + dragOver + ? isDark ? "border-udc-gold bg-udc-gold/5" : "border-blue-400 bg-blue-50" + : isDark ? "border-panel-border hover:border-slate-500" : "border-slate-300 hover:border-slate-400" + }`} + > + { + const file = e.target.files?.[0]; + if (file) handleFileSelect(file); + }} + /> + {uploading ? ( +
+ +

Processing upload...

+
+ ) : ( +
+ +
+

+ Drop your {uploadType === "readings" ? "readings" : "stations"} file here +

+

+ Supports CSV and JSON • Click to browse +

+
+
+ )} +
+ + {/* Expected format hint */} +
+

+ + Expected Format — {uploadType === "readings" ? "Readings" : "Stations"} +

+
+ {uploadType === "readings" ? ( + <> +
# CSV columns (names are flexible — auto-mapped):
+
station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, conductivity, ecoli_count, nitrate_n, phosphorus
+
# Example:
+
ANA-001, 2026-03-10T10:00:00, 12.5, 8.2, 7.1, 15.3, 320, 85, 1.2, 0.08
+ + ) : ( + <> +
# Required columns:
+
id, name, latitude, longitude, type
+
# Optional: status, parameters
+
# type must be: river | stream | stormwater | green-infrastructure
+ + )} +
+

+ Column aliases supported: temp, DO, water_temp, e_coli, specific_conductance, etc. +

+
+ + {/* AI Column Mapping Preview */} + {preview && ( +
+
+
+ +

+ Column Mapping Preview +

+ {preview.aiMapping?.method === "ai" && ( + AI-assisted + )} + {preview.loadingAI && ( + Analyzing columns... + )} +
+ +
+ +

+ File: {preview.file.name} • {preview.sampleRows.length} sample rows • {preview.columns.length} columns detected +

+ + {/* Mapping Table */} +
+ + + + + + + + + + {preview.columns.map((col) => { + const mapped = customMapping[col]; + const schemaFields = uploadType === "stations" + ? ["id", "name", "latitude", "longitude", "type", "status", "parameters"] + : ["station_id", "timestamp", "temperature", "dissolved_oxygen", "ph", "turbidity", "conductivity", "ecoli_count", "nitrate_n", "phosphorus", "source"]; + + return ( + + + + + + ); + })} + +
Your ColumnMaps ToSample Value
{col} + + + {preview.sampleRows[0]?.[col] || "—"} +
+
+ + {/* Sample Data Preview */} + {preview.sampleRows.length > 0 && ( +
+ + Preview first {preview.sampleRows.length} rows + +
+ + + + {preview.columns.map((c) => ( + + ))} + + + + {preview.sampleRows.map((row, i) => ( + + {preview.columns.map((c) => ( + + ))} + + ))} + +
{c}
{row[c] || ""}
+
+
+ )} + + {/* Upload Button */} +
+ + +
+
+ )} + + {/* Upload Result */} + {result && } +
+ ); +} + +function UploadResultPanel({ result, isDark }: { result: UploadResult; isDark: boolean }) { + const hasErrors = result.errors.length > 0; + const hasWarnings = result.warnings.length > 0; + const allFailed = result.inserted === 0 && hasErrors; + + return ( +
+ {/* Summary */} +
+ {allFailed ? ( + + ) : hasErrors ? ( + + ) : ( + + )} +
+

+ {allFailed ? "Upload Failed" : hasErrors ? "Partial Upload" : "Upload Successful"} +

+

+ {result.inserted} of {result.total_rows} {result.type} records inserted +

+
+
+ + {/* Column mapping */} + {Object.keys(result.column_mapping).length > 0 && ( +
+

Column Mapping:

+
+ {Object.entries(result.column_mapping).map(([from, to]) => ( + + {from} → {to} + + ))} +
+
+ )} + + {/* Warnings */} + {hasWarnings && ( +
+

Warnings:

+
    + {result.warnings.slice(0, 10).map((w, i) => ( +
  • • {w}
  • + ))} + {result.warnings.length > 10 &&
  • ... and {result.warnings.length - 10} more
  • } +
+
+ )} + + {/* Errors */} + {hasErrors && ( +
+

Errors:

+
    + {result.errors.slice(0, 10).map((e, i) => ( +
  • • {e}
  • + ))} + {result.errors.length > 10 &&
  • ... and {result.errors.length - 10} more
  • } +
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Stations Tab +// --------------------------------------------------------------------------- +function StationsTab({ isDark, adminKey }: { isDark: boolean; adminKey: string }) { + const [stations, setStations] = useState([]); + const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editData, setEditData] = useState>({}); + const [showAddForm, setShowAddForm] = useState(false); + const [newStation, setNewStation] = useState({ + id: "", name: "", latitude: "", longitude: "", type: "river", status: "active", + }); + + const fetchStations = useCallback(async () => { + setLoading(true); + const res = await fetch("/api/admin/stations", { headers: authHeadersNoBody(adminKey) }); + const data = await res.json(); + setStations(data.stations || []); + setLoading(false); + }, [adminKey]); + + useEffect(() => { fetchStations(); }, [fetchStations]); + + const handleAdd = async () => { + if (!newStation.id || !newStation.name || !newStation.latitude || !newStation.longitude) { + alert("All fields are required"); + return; + } + const res = await fetch("/api/admin/stations", { + method: "POST", + headers: authHeaders(adminKey), + body: JSON.stringify({ + ...newStation, + latitude: parseFloat(newStation.latitude), + longitude: parseFloat(newStation.longitude), + }), + }); + if (res.ok) { + setShowAddForm(false); + setNewStation({ id: "", name: "", latitude: "", longitude: "", type: "river", status: "active" }); + fetchStations(); + } else { + const err = await res.json(); + alert(err.error); + } + }; + + const handleSaveEdit = async () => { + if (!editingId) return; + const res = await fetch("/api/admin/stations", { + method: "PUT", + headers: authHeaders(adminKey), + body: JSON.stringify({ id: editingId, ...editData }), + }); + if (res.ok) { + setEditingId(null); + fetchStations(); + } else { + const err = await res.json(); + alert(err.error); + } + }; + + const handleDelete = async (id: string) => { + if (!confirm(`Delete station ${id} and all its readings? This cannot be undone.`)) return; + await fetch("/api/admin/stations", { + method: "DELETE", + headers: authHeaders(adminKey), + body: JSON.stringify({ id }), + }); + fetchStations(); + }; + + return ( +
+
+
+

Monitoring Stations

+

{stations.length} stations registered

+
+
+ + +
+
+ + {/* Add Form */} + {showAddForm && ( +
+

New Station

+
+ {[ + { key: "id", label: "Station ID", placeholder: "e.g. ANA-005" }, + { key: "name", label: "Name", placeholder: "e.g. Anacostia at Navy Yard" }, + { key: "latitude", label: "Latitude", placeholder: "38.8xxx" }, + { key: "longitude", label: "Longitude", placeholder: "-76.9xxx" }, + ].map((field) => ( +
+ + setNewStation({ ...newStation, [field.key]: e.target.value })} + placeholder={field.placeholder} + className={`w-full px-2.5 py-1.5 rounded-lg border text-xs ${isDark ? "bg-udc-dark border-panel-border text-slate-300" : "bg-slate-50 border-slate-200"}`} + /> +
+ ))} +
+ + +
+
+ + +
+
+
+ + +
+
+ )} + + {/* Table */} + {loading ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + + + + + + {stations.map((s) => ( + + {editingId === s.id ? ( + <> + + + + + + + + + + ) : ( + <> + + + + + + + + + + )} + + ))} + +
IDNameTypeStatusLatLngReadingsActions
{s.id} + setEditData({ ...editData, name: e.target.value })} + className={`w-full px-1.5 py-0.5 rounded border text-xs ${isDark ? "bg-udc-dark border-panel-border text-slate-300" : "bg-white border-slate-200"}`} + /> + + + + + + setEditData({ ...editData, latitude: parseFloat(e.target.value) })} + className={`w-20 px-1.5 py-0.5 rounded border text-xs text-right ${isDark ? "bg-udc-dark border-panel-border text-slate-300" : "bg-white border-slate-200"}`} + /> + + setEditData({ ...editData, longitude: parseFloat(e.target.value) })} + className={`w-20 px-1.5 py-0.5 rounded border text-xs text-right ${isDark ? "bg-udc-dark border-panel-border text-slate-300" : "bg-white border-slate-200"}`} + /> + {s.reading_count} +
+ + +
+
{s.id}{s.name} + + {s.type} + + + + {s.status} + + {Number(s.latitude).toFixed(4)}{Number(s.longitude).toFixed(4)}{s.reading_count} +
+ + +
+
+
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Readings Tab +// --------------------------------------------------------------------------- +function ReadingsTab({ isDark, adminKey }: { isDark: boolean; adminKey: string }) { + const [readings, setReadings] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [stationFilter, setStationFilter] = useState(""); + const [offset, setOffset] = useState(0); + const limit = 50; + + const fetchReadings = useCallback(async () => { + setLoading(true); + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (stationFilter) params.set("station", stationFilter); + + const res = await fetch(`/api/admin/readings?${params}`, { + headers: authHeadersNoBody(adminKey), + }); + const data = await res.json(); + setReadings(data.readings || []); + setTotal(data.total || 0); + setLoading(false); + }, [adminKey, offset, stationFilter]); + + useEffect(() => { fetchReadings(); }, [fetchReadings]); + + const handleDelete = async (id: number) => { + if (!confirm("Delete this reading?")) return; + await fetch("/api/admin/readings", { + method: "DELETE", + headers: authHeaders(adminKey), + body: JSON.stringify({ id }), + }); + fetchReadings(); + }; + + const formatVal = (v: number | null) => v != null ? v.toFixed(2) : "—"; + + return ( +
+
+
+

Water Quality Readings

+

{total} total readings

+
+
+ { setStationFilter(e.target.value); setOffset(0); }} + placeholder="Filter by station ID..." + className={`px-3 py-1.5 rounded-lg border text-xs w-44 ${isDark ? "bg-udc-dark border-panel-border text-slate-300 placeholder:text-slate-600" : "bg-white border-slate-200"}`} + /> + + + + +
+
+ + {loading ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + + + + + + + + {readings.map((r) => ( + + + + + + + + + + + + + ))} + +
StationTimestampTemp °CDO mg/LpHTurb NTUCondE.coliSourceDel
{r.station_id} + {new Date(r.timestamp).toLocaleString()} + {formatVal(r.temperature)}{formatVal(r.dissolved_oxygen)}{formatVal(r.ph)}{formatVal(r.turbidity)}{formatVal(r.conductivity)} 410 ? "text-red-400 font-semibold" : isDark ? "text-slate-400" : "text-slate-500" + }`}>{formatVal(r.ecoli_count)} + + {r.source} + + + +
+
+ + {/* Pagination */} +
+

+ Showing {offset + 1}–{Math.min(offset + limit, total)} of {total} +

+
+ + +
+
+
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Ingestion Logs Tab +// --------------------------------------------------------------------------- +function LogsTab({ isDark, adminKey }: { isDark: boolean; adminKey: string }) { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [triggerSource, setTriggerSource] = useState("usgs"); + const [triggering, setTriggering] = useState(false); + + const fetchLogs = useCallback(async () => { + setLoading(true); + const res = await fetch("/api/ingestion-log?limit=100"); + const data = await res.json(); + setLogs(data.logs || []); + setLoading(false); + }, []); + + useEffect(() => { fetchLogs(); }, [fetchLogs]); + + const triggerIngest = async () => { + setTriggering(true); + try { + const headers: Record = { "Content-Type": "application/json" }; + const ingestKey = adminKey || ""; + if (ingestKey) headers["Authorization"] = `Bearer ${ingestKey}`; + + const res = await fetch(`/api/ingest?source=${triggerSource}`, { + method: "POST", + headers, + }); + const data = await res.json(); + alert(`Ingestion complete: ${data.records_ingested || 0} records. ${data.errors?.length ? `Errors: ${data.errors.join(", ")}` : "No errors."}`); + fetchLogs(); + } catch (err) { + alert(`Ingestion failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setTriggering(false); + } + }; + + return ( +
+
+
+

Ingestion Log

+

History of all data ingestion runs

+
+
+ + +
+
+ + {loading ? ( +
+ +
+ ) : logs.length === 0 ? ( +
+ No ingestion logs yet. Run an ingestion to get started. +
+ ) : ( +
+
+ + + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + + ))} + +
SourceStatusRecordsStartedCompletedError
+ + {log.source} + + + {log.status === "success" ? ( + Success + ) : ( + Error + )} + {log.records_count} + {log.started_at ? new Date(log.started_at).toLocaleString() : "—"} + + {log.completed_at ? new Date(log.completed_at).toLocaleString() : "—"} + + {log.error_message || "—"} +
+
+
+ )} +
+ ); +} diff --git a/src/app/api/admin/ai-map-columns/route.ts b/src/app/api/admin/ai-map-columns/route.ts new file mode 100644 index 0000000..014d391 --- /dev/null +++ b/src/app/api/admin/ai-map-columns/route.ts @@ -0,0 +1,151 @@ +import { NextRequest, NextResponse } from "next/server"; + +function checkAuth(request: NextRequest): NextResponse | null { + const adminKey = process.env.ADMIN_API_KEY; + + if (!adminKey && process.env.NODE_ENV === "production") { + return NextResponse.json( + { error: "ADMIN_API_KEY not configured. Admin access is disabled." }, + { status: 503 } + ); + } + + const authHeader = request.headers.get("authorization"); + if (adminKey && authHeader !== `Bearer ${adminKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return null; +} + +/** + * POST /api/admin/ai-map-columns + * Uses Claude to intelligently map uploaded column names to our schema. + * Falls back to basic heuristics if ANTHROPIC_API_KEY is not set. + */ +export async function POST(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const { columns, sampleRows, dataType } = await request.json(); + + if (!columns || !Array.isArray(columns)) { + return NextResponse.json({ error: "columns array required" }, { status: 400 }); + } + + const schemaFields = dataType === "stations" + ? { + id: "Unique station identifier (e.g. ANA-001)", + name: "Human-readable station name", + latitude: "GPS latitude (decimal degrees)", + longitude: "GPS longitude (decimal degrees)", + type: "Station type: river, stream, stormwater, or green-infrastructure", + status: "Operational status: active, maintenance, or offline", + parameters: "JSON array of measured parameters", + } + : { + station_id: "Station identifier (e.g. ANA-001)", + timestamp: "Date/time of measurement (ISO 8601)", + temperature: "Water temperature in °C", + dissolved_oxygen: "Dissolved oxygen in mg/L", + ph: "pH value (0-14 scale)", + turbidity: "Turbidity in NTU", + conductivity: "Specific conductance in µS/cm", + ecoli_count: "E. coli count in CFU/100mL", + nitrate_n: "Nitrate-nitrogen in mg/L", + phosphorus: "Total phosphorus in mg/L", + source: "Data source identifier (e.g. usgs, epa, manual)", + }; + + const apiKey = process.env.ANTHROPIC_API_KEY; + + if (apiKey) { + // Use Claude for intelligent mapping + try { + const prompt = `You are a data schema mapping assistant for a water quality monitoring system. + +Given these uploaded CSV/JSON column names: +${JSON.stringify(columns)} + +${sampleRows ? `Sample data rows:\n${JSON.stringify(sampleRows.slice(0, 3), null, 2)}` : ""} + +Map each column to the closest matching field in our database schema: +${JSON.stringify(schemaFields, null, 2)} + +Rules: +- Only map columns that clearly correspond to a schema field +- Leave unmapped columns as null +- Consider unit variations (e.g., "DO_mg_L" → "dissolved_oxygen") +- Consider abbreviations (e.g., "temp" → "temperature", "EC" → "ecoli_count") +- If a column contains dates/times, map it to "timestamp" +- If sample data helps clarify (e.g., values 0-14 are likely pH), use that + +Respond with ONLY a JSON object mapping each input column name to either a schema field name or null: +{"column_name": "schema_field_or_null", ...}`; + + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-haiku-4-5-20251001", + max_tokens: 1024, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (response.ok) { + const data = await response.json(); + const text = data.content?.[0]?.text || ""; + // Extract JSON from response + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (jsonMatch) { + const mapping = JSON.parse(jsonMatch[0]); + return NextResponse.json({ + mapping, + method: "ai", + model: "claude-haiku-4-5", + }); + } + } + } catch { + // Fall through to heuristic mapping + } + } + + // Fallback: basic heuristic mapping + const ALIASES: Record = { + station_id: "station_id", stationid: "station_id", station: "station_id", + site_id: "station_id", site: "station_id", monitoring_location: "station_id", + timestamp: "timestamp", date: "timestamp", datetime: "timestamp", + date_time: "timestamp", sample_date: "timestamp", + temperature: "temperature", temp: "temperature", water_temp: "temperature", + dissolved_oxygen: "dissolved_oxygen", do: "dissolved_oxygen", + ph: "ph", turbidity: "turbidity", turb: "turbidity", + conductivity: "conductivity", cond: "conductivity", specific_conductance: "conductivity", + ecoli_count: "ecoli_count", ecoli: "ecoli_count", e_coli: "ecoli_count", + nitrate_n: "nitrate_n", nitrate: "nitrate_n", no3_n: "nitrate_n", + phosphorus: "phosphorus", total_phosphorus: "phosphorus", tp: "phosphorus", + source: "source", + id: "id", name: "name", latitude: "latitude", lat: "latitude", + longitude: "longitude", lng: "longitude", lon: "longitude", + type: "type", status: "status", parameters: "parameters", + }; + + const mapping: Record = {}; + const validFields = new Set(Object.keys(schemaFields)); + + for (const col of columns) { + const normalized = col.toLowerCase().trim().replace(/[\s\-()°µ/]+/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, ""); + const mapped = ALIASES[normalized]; + mapping[col] = mapped && validFields.has(mapped) ? mapped : null; + } + + return NextResponse.json({ + mapping, + method: "heuristic", + }); +} diff --git a/src/app/api/admin/readings/route.ts b/src/app/api/admin/readings/route.ts new file mode 100644 index 0000000..15108ca --- /dev/null +++ b/src/app/api/admin/readings/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +function checkAuth(request: NextRequest): NextResponse | null { + const adminKey = process.env.ADMIN_API_KEY; + + if (!adminKey && process.env.NODE_ENV === "production") { + return NextResponse.json( + { error: "ADMIN_API_KEY not configured. Admin access is disabled." }, + { status: 503 } + ); + } + + const authHeader = request.headers.get("authorization"); + if (adminKey && authHeader !== `Bearer ${adminKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return null; +} + +// GET — List readings with optional filters +export async function GET(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const searchParams = request.nextUrl.searchParams; + const stationId = searchParams.get("station"); + const limit = Math.min(parseInt(searchParams.get("limit") || "100"), 500); + const offset = parseInt(searchParams.get("offset") || "0"); + + let query = `SELECT r.*, s.name AS station_name FROM readings r JOIN stations s ON s.id = r.station_id`; + const params: unknown[] = []; + + if (stationId) { + query += " WHERE r.station_id = ?"; + params.push(stationId); + } + + query += " ORDER BY r.timestamp DESC LIMIT ? OFFSET ?"; + params.push(limit, offset); + + const { rows } = await db.query(query, params); + + // Get total count + let countQuery = "SELECT COUNT(*) AS total FROM readings"; + const countParams: unknown[] = []; + if (stationId) { + countQuery += " WHERE station_id = ?"; + countParams.push(stationId); + } + const { rows: countRows } = await db.query(countQuery, countParams); + const total = (countRows[0]?.total as number) || 0; + + return NextResponse.json({ readings: rows, total, limit, offset }); +} + +// POST — Add reading(s) +export async function POST(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const body = await request.json(); + const readings = Array.isArray(body) ? body : [body]; + + let inserted = 0; + const errors: string[] = []; + + for (const r of readings) { + if (!r.station_id || !r.timestamp) { + errors.push(`Missing station_id or timestamp in record`); + continue; + } + + try { + await db.query( + `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, conductivity, ecoli_count, nitrate_n, phosphorus, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + r.station_id, + r.timestamp, + r.temperature ?? null, + r.dissolved_oxygen ?? null, + r.ph ?? null, + r.turbidity ?? null, + r.conductivity ?? null, + r.ecoli_count ?? null, + r.nitrate_n ?? null, + r.phosphorus ?? null, + r.source || "manual", + ] + ); + inserted++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${r.station_id} @ ${r.timestamp}: ${msg}`); + } + } + + return NextResponse.json({ inserted, errors }, { status: errors.length > 0 ? 207 : 201 }); +} + +// DELETE — Remove a reading by id +export async function DELETE(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: "Reading id is required" }, { status: 400 }); + } + + const result = await db.query("DELETE FROM readings WHERE id = ?", [id]); + + if (result.changes === 0) { + return NextResponse.json({ error: `Reading ${id} not found` }, { status: 404 }); + } + + return NextResponse.json({ success: true, deleted: id }); +} diff --git a/src/app/api/admin/stations/route.ts b/src/app/api/admin/stations/route.ts new file mode 100644 index 0000000..96efc58 --- /dev/null +++ b/src/app/api/admin/stations/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +function checkAuth(request: NextRequest): NextResponse | null { + const adminKey = process.env.ADMIN_API_KEY; + + // In production, ADMIN_API_KEY must be configured — block all access if missing + if (!adminKey && process.env.NODE_ENV === "production") { + return NextResponse.json( + { error: "ADMIN_API_KEY not configured. Admin access is disabled." }, + { status: 503 } + ); + } + + // If key is set, require valid Bearer token + const authHeader = request.headers.get("authorization"); + if (adminKey && authHeader !== `Bearer ${adminKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return null; +} + +// GET — List all stations +export async function GET(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const { rows } = await db.query( + `SELECT s.*, + (SELECT COUNT(*) FROM readings WHERE station_id = s.id) AS reading_count, + (SELECT MAX(timestamp) FROM readings WHERE station_id = s.id) AS last_reading + FROM stations s ORDER BY s.id` + ); + + return NextResponse.json({ stations: rows }); +} + +// POST — Add a new station +export async function POST(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const body = await request.json(); + const { id, name, latitude, longitude, type, status, parameters } = body; + + if (!id || !name || latitude == null || longitude == null || !type) { + return NextResponse.json( + { error: "Missing required fields: id, name, latitude, longitude, type" }, + { status: 400 } + ); + } + + const validTypes = ["river", "stream", "stormwater", "green-infrastructure"]; + if (!validTypes.includes(type)) { + return NextResponse.json( + { error: `Invalid type. Must be one of: ${validTypes.join(", ")}` }, + { status: 400 } + ); + } + + try { + await db.query( + `INSERT INTO stations (id, name, latitude, longitude, type, status, parameters) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + id, + name, + latitude, + longitude, + type, + status || "active", + JSON.stringify(parameters || ["temperature", "dissolved_oxygen", "ph", "turbidity"]), + ] + ); + + return NextResponse.json({ success: true, station: { id, name, type } }, { status: 201 }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("UNIQUE") || msg.includes("duplicate")) { + return NextResponse.json({ error: `Station ${id} already exists` }, { status: 409 }); + } + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +// PUT — Update a station +export async function PUT(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const body = await request.json(); + const { id, name, latitude, longitude, type, status, parameters } = body; + + if (!id) { + return NextResponse.json({ error: "Station id is required" }, { status: 400 }); + } + + const sets: string[] = []; + const params: unknown[] = []; + + if (name) { sets.push("name = ?"); params.push(name); } + if (latitude != null) { sets.push("latitude = ?"); params.push(latitude); } + if (longitude != null) { sets.push("longitude = ?"); params.push(longitude); } + if (type) { sets.push("type = ?"); params.push(type); } + if (status) { sets.push("status = ?"); params.push(status); } + if (parameters) { sets.push("parameters = ?"); params.push(JSON.stringify(parameters)); } + + if (sets.length === 0) { + return NextResponse.json({ error: "No fields to update" }, { status: 400 }); + } + + const nowFn = process.env.DATABASE_URL ? "NOW()" : "datetime('now')"; + sets.push(`updated_at = ${nowFn}`); + params.push(id); + + const result = await db.query( + `UPDATE stations SET ${sets.join(", ")} WHERE id = ?`, + params + ); + + if (result.changes === 0) { + return NextResponse.json({ error: `Station ${id} not found` }, { status: 404 }); + } + + return NextResponse.json({ success: true, updated: id }); +} + +// DELETE — Remove a station and its readings +export async function DELETE(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const db = await getDbClient(); + const { id } = await request.json(); + + if (!id) { + return NextResponse.json({ error: "Station id is required" }, { status: 400 }); + } + + await db.query("DELETE FROM readings WHERE station_id = ?", [id]); + const result = await db.query("DELETE FROM stations WHERE id = ?", [id]); + + if (result.changes === 0) { + return NextResponse.json({ error: `Station ${id} not found` }, { status: 404 }); + } + + return NextResponse.json({ success: true, deleted: id }); +} diff --git a/src/app/api/admin/upload/route.ts b/src/app/api/admin/upload/route.ts new file mode 100644 index 0000000..12da121 --- /dev/null +++ b/src/app/api/admin/upload/route.ts @@ -0,0 +1,331 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +function checkAuth(request: NextRequest): NextResponse | null { + const adminKey = process.env.ADMIN_API_KEY; + + if (!adminKey && process.env.NODE_ENV === "production") { + return NextResponse.json( + { error: "ADMIN_API_KEY not configured. Admin access is disabled." }, + { status: 503 } + ); + } + + const authHeader = request.headers.get("authorization"); + if (adminKey && authHeader !== `Bearer ${adminKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return null; +} + +// Valid ranges for data validation +const VALID_RANGES: Record = { + temperature: { min: -5, max: 45, label: "Temperature (°C)" }, + dissolved_oxygen: { min: 0, max: 20, label: "Dissolved Oxygen (mg/L)" }, + ph: { min: 0, max: 14, label: "pH" }, + turbidity: { min: 0, max: 4000, label: "Turbidity (NTU)" }, + conductivity: { min: 0, max: 10000, label: "Conductivity (µS/cm)" }, + ecoli_count: { min: 0, max: 100000, label: "E. coli (CFU/100mL)" }, + nitrate_n: { min: 0, max: 100, label: "Nitrate-N (mg/L)" }, + phosphorus: { min: 0, max: 50, label: "Phosphorus (mg/L)" }, +}; + +// Known column aliases that map to our schema fields +const COLUMN_ALIASES: Record = { + // station_id aliases + station_id: "station_id", + stationid: "station_id", + station: "station_id", + site_id: "station_id", + siteid: "station_id", + site: "station_id", + monitoring_location: "station_id", + + // timestamp aliases + timestamp: "timestamp", + date: "timestamp", + datetime: "timestamp", + date_time: "timestamp", + sample_date: "timestamp", + collection_date: "timestamp", + activity_start_date: "timestamp", + + // temperature aliases + temperature: "temperature", + temp: "temperature", + water_temp: "temperature", + water_temperature: "temperature", + "temperature_c": "temperature", + "temp_c": "temperature", + + // dissolved oxygen + dissolved_oxygen: "dissolved_oxygen", + do: "dissolved_oxygen", + do_mgl: "dissolved_oxygen", + "dissolved_oxygen_mgl": "dissolved_oxygen", + + // pH + ph: "ph", + "ph_units": "ph", + + // turbidity + turbidity: "turbidity", + turb: "turbidity", + "turbidity_ntu": "turbidity", + + // conductivity + conductivity: "conductivity", + cond: "conductivity", + specific_conductance: "conductivity", + "conductivity_uscm": "conductivity", + spc: "conductivity", + + // E. coli + ecoli_count: "ecoli_count", + ecoli: "ecoli_count", + e_coli: "ecoli_count", + "ecoli_cfu": "ecoli_count", + "e_coli_cfu_100ml": "ecoli_count", + escherichia_coli: "ecoli_count", + + // nitrate + nitrate_n: "nitrate_n", + nitrate: "nitrate_n", + "nitrate_mgl": "nitrate_n", + no3_n: "nitrate_n", + + // phosphorus + phosphorus: "phosphorus", + total_phosphorus: "phosphorus", + "phosphorus_mgl": "phosphorus", + tp: "phosphorus", +}; + +function normalizeColumnName(name: string): string { + const normalized = name + .toLowerCase() + .trim() + .replace(/[()°µ\/]/g, "") + .replace(/[\s\-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + return COLUMN_ALIASES[normalized] || normalized; +} + +function parseCSV(text: string): { headers: string[]; rows: Record[] } { + const lines = text.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#")); + if (lines.length < 2) return { headers: [], rows: [] }; + + const headers = lines[0].split(",").map((h) => h.trim().replace(/^"|"$/g, "")); + const rows: Record[] = []; + + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(",").map((v) => v.trim().replace(/^"|"$/g, "")); + const row: Record = {}; + headers.forEach((h, j) => { + row[h] = values[j] || ""; + }); + rows.push(row); + } + + return { headers, rows }; +} + +export async function POST(request: NextRequest) { + const authErr = checkAuth(request); + if (authErr) return authErr; + + const contentType = request.headers.get("content-type") || ""; + + let rawHeaders: string[] = []; + let rawRows: Record[] = []; + let dataType: "stations" | "readings" = "readings"; + + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData(); + const file = formData.get("file") as File | null; + dataType = (formData.get("type") as "stations" | "readings") || "readings"; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + const text = await file.text(); + const fileName = file.name.toLowerCase(); + + if (fileName.endsWith(".json")) { + const json = JSON.parse(text); + const data = Array.isArray(json) ? json : json.data || json.readings || json.stations || [json]; + rawHeaders = data.length > 0 ? Object.keys(data[0]) : []; + rawRows = data.map((row: Record) => { + const r: Record = {}; + for (const [k, v] of Object.entries(row)) { + r[k] = v == null ? "" : String(v); + } + return r; + }); + } else { + // CSV + const parsed = parseCSV(text); + rawHeaders = parsed.headers; + rawRows = parsed.rows; + } + } else { + // JSON body + const body = await request.json(); + dataType = body.type || "readings"; + const data = Array.isArray(body.data) ? body.data : [body.data]; + rawHeaders = data.length > 0 ? Object.keys(data[0]) : []; + rawRows = data; + } + + if (rawRows.length === 0) { + return NextResponse.json({ error: "No data rows found" }, { status: 400 }); + } + + // Map columns + const columnMapping: Record = {}; + const unmapped: string[] = []; + for (const header of rawHeaders) { + const mapped = normalizeColumnName(header); + if (mapped !== normalizeColumnName(header) || COLUMN_ALIASES[mapped]) { + columnMapping[header] = COLUMN_ALIASES[mapped] || mapped; + } else { + // Check if it's a known schema field + const schemaFields = dataType === "stations" + ? ["id", "name", "latitude", "longitude", "type", "status", "parameters"] + : ["station_id", "timestamp", "temperature", "dissolved_oxygen", "ph", "turbidity", "conductivity", "ecoli_count", "nitrate_n", "phosphorus", "source"]; + + if (schemaFields.includes(mapped)) { + columnMapping[header] = mapped; + } else { + unmapped.push(header); + } + } + } + + const db = await getDbClient(); + let inserted = 0; + const errors: string[] = []; + const warnings: string[] = []; + + if (unmapped.length > 0) { + warnings.push(`Unmapped columns (ignored): ${unmapped.join(", ")}`); + } + + if (dataType === "stations") { + for (let i = 0; i < rawRows.length; i++) { + const raw = rawRows[i]; + const row: Record = {}; + for (const [origCol, value] of Object.entries(raw)) { + const mappedCol = columnMapping[origCol]; + if (mappedCol) row[mappedCol] = value; + } + + if (!row.id || !row.name || !row.latitude || !row.longitude || !row.type) { + errors.push(`Row ${i + 1}: Missing required fields (id, name, latitude, longitude, type)`); + continue; + } + + try { + await db.query( + `INSERT INTO stations (id, name, latitude, longitude, type, status, parameters) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + row.id, + row.name, + parseFloat(row.latitude), + parseFloat(row.longitude), + row.type, + row.status || "active", + row.parameters || '["temperature","dissolved_oxygen","ph","turbidity"]', + ] + ); + inserted++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`Row ${i + 1} (${row.id}): ${msg}`); + } + } + } else { + // Readings + for (let i = 0; i < rawRows.length; i++) { + const raw = rawRows[i]; + const row: Record = {}; + for (const [origCol, value] of Object.entries(raw)) { + const mappedCol = columnMapping[origCol]; + if (mappedCol) row[mappedCol] = value; + } + + if (!row.station_id || !row.timestamp) { + errors.push(`Row ${i + 1}: Missing station_id or timestamp`); + continue; + } + + // Validate numeric ranges + const values: Record = {}; + for (const field of Object.keys(VALID_RANGES)) { + const val = row[field] ? parseFloat(row[field]) : null; + if (val !== null && !isNaN(val)) { + const range = VALID_RANGES[field]; + if (val < range.min || val > range.max) { + warnings.push(`Row ${i + 1}: ${range.label} = ${val} out of range [${range.min}, ${range.max}] — set to null`); + values[field] = null; + } else { + values[field] = val; + } + } else { + values[field] = null; + } + } + + try { + await db.query( + `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, conductivity, ecoli_count, nitrate_n, phosphorus, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + row.station_id, + row.timestamp, + values.temperature, + values.dissolved_oxygen, + values.ph, + values.turbidity, + values.conductivity, + values.ecoli_count, + values.nitrate_n, + values.phosphorus, + row.source || "upload", + ] + ); + inserted++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`Row ${i + 1} (${row.station_id}): ${msg}`); + } + } + } + + // Log the upload + const nowFn = process.env.DATABASE_URL ? "NOW()" : "datetime('now')"; + await db.query( + `INSERT INTO ingestion_log (source, status, records_count, error_message, completed_at) + VALUES (?, ?, ?, ?, ${nowFn})`, + [ + `upload-${dataType}`, + errors.length === 0 ? "success" : "error", + inserted, + errors.length > 0 ? errors.slice(0, 10).join("; ") : null, + ] + ).catch(() => {}); + + return NextResponse.json({ + type: dataType, + inserted, + total_rows: rawRows.length, + column_mapping: columnMapping, + unmapped_columns: unmapped, + warnings, + errors, + }, { status: errors.length > 0 && inserted === 0 ? 400 : errors.length > 0 ? 207 : 201 }); +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts new file mode 100644 index 0000000..744cbf2 --- /dev/null +++ b/src/app/api/chat/route.ts @@ -0,0 +1,266 @@ +import { + streamText, + convertToModelMessages, + UIMessage, + tool, + stepCountIs, + jsonSchema, +} from "ai"; +import { anthropic } from "@ai-sdk/anthropic"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const maxDuration = 60; + +// Configurable model via env var (default: Claude Haiku 4.5) +const CHAT_MODEL = process.env.CHAT_MODEL || "claude-haiku-4-5-20251001"; + +const SYSTEM_PROMPT = `You are the UDC Water Resources Research Assistant, an AI-powered tool built into the University of the District of Columbia's Water Resources Data Dashboard. You help researchers, students, and community members understand water quality data across the Anacostia River watershed in Washington, DC. + +## Your Knowledge Domain + +**Monitoring Network:** +- 12 stations across the Anacostia watershed: 4 river stations (ANA-001 to ANA-004), 3 tributary stations (WB-001 Watts Branch, PB-001 Pope Branch, HR-001 Hickey Run), 3 green infrastructure sites (GI-001 UDC Van Ness Green Roof, GI-002 East Capitol Urban Farm, GI-003 PR Harris Food Hub), and 2 stormwater outfalls (SW-001 Benning Road, SW-002 South Capitol) +- Data sourced from USGS NWIS, EPA Water Quality Portal, DC DOEE, and UDC field measurements + +**EPA Water Quality Standards (DC recreational waters):** +- Dissolved Oxygen (DO): minimum 5.0 mg/L (below = aquatic stress) +- pH: 6.5–9.0 +- E. coli: max 410 CFU/100mL (geometric mean 126 CFU/100mL) +- Temperature: species-dependent, warm-water fishery <32°C +- Turbidity: <50 NTU for healthy conditions +- Conductivity: typical freshwater 150–500 µS/cm; >1000 µS/cm indicates pollution +- Nitrate-N: <10 mg/L (drinking water standard) +- Total Phosphorus: <0.1 mg/L to prevent eutrophication + +**Seasonal Patterns (Anacostia watershed):** +- Winter (Dec–Feb): Low temps (3–6°C), high DO (10–12 mg/L), low E. coli (<200 CFU) +- Spring (Mar–May): Rising temps (8–15°C), moderate DO (8–10 mg/L), increasing E. coli from runoff +- Summer (Jun–Aug): High temps (24–28°C), LOW DO (5–7 mg/L, often near EPA minimum), HIGHEST E. coli (>1000 CFU after storms), algal blooms +- Fall (Sep–Nov): Declining temps (10–18°C), recovering DO (7–9 mg/L) + +**Key Environmental Issues:** +- Combined Sewer Overflows (CSOs) discharge raw sewage during heavy rain, primarily affecting Wards 7 and 8 +- PFAS emerging contaminants detected in sediment +- Environmental justice: Wards 7 & 8 have highest flood risk, highest impervious surface coverage, lowest green space access +- DC Water's Clean Rivers Project (tunnel system) aims to reduce CSO volume by 96% + +**UDC Research (CAUSES/WRRI):** +- Director: Dr. Tolessa Deksissa +- Focus areas: green roof stormwater retention, urban food hub BMPs, PFAS assessment, Potomac source water protection, tree cell filtration, rainwater reuse safety +- Environmental Quality Testing Laboratory (EQTL) conducts certified water analyses + +## How to Respond + +1. **Be scientifically accurate.** Cite EPA thresholds when discussing water quality. Use proper units (mg/L, CFU/100mL, µS/cm, NTU). +2. **Interpret data in context.** Don't just state numbers — explain what they mean for aquatic health, public safety, and communities. +3. **Flag anomalies.** If a user asks about high E. coli or low DO, explain likely causes (CSOs, seasonal warming, nutrient loading). +4. **Suggest next steps.** Point users to the dashboard's station detail pages, export tools, or research portal when relevant. +5. **Be accessible.** Explain technical concepts clearly for students and community members, not just experts. +6. **Stay grounded.** If you don't have specific data, say so. Recommend checking the station detail page or exporting data for analysis. +7. **Use the tools available** to query real station data when users ask about specific readings or trends.`; + +export async function POST(req: Request) { + // --- CSRF / Origin protection --- + const origin = req.headers.get("origin"); + const host = req.headers.get("host"); + if (origin && host) { + try { + const originHost = new URL(origin).host; + if (originHost !== host) { + return Response.json( + { error: "Cross-origin requests are not allowed" }, + { status: 403 }, + ); + } + } catch { + return Response.json( + { error: "Invalid origin header" }, + { status: 403 }, + ); + } + } + + // --- Rate limiting (10 requests per minute per IP) --- + const clientIp = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || + req.headers.get("x-real-ip") || + "unknown"; + const rateResult = checkRateLimit(`chat:${clientIp}`, { + limit: 10, + windowMs: 60_000, + }); + + if (!rateResult.allowed) { + return Response.json( + { error: "Too many requests. Please wait a moment before trying again." }, + { + status: 429, + headers: { + "Retry-After": String( + Math.ceil((rateResult.resetAt - Date.now()) / 1000), + ), + "X-RateLimit-Remaining": "0", + }, + }, + ); + } + + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + return Response.json( + { + error: + "AI assistant is not configured. Set ANTHROPIC_API_KEY in environment variables.", + }, + { status: 503 }, + ); + } + + // Derive base URL from request for tool calls (works on Vercel, Docker, and local dev) + const forwardedProto = req.headers.get("x-forwarded-proto") || "https"; + const hostHeader = req.headers.get("host") || new URL(req.url).host; + const baseUrl = + process.env.NEXT_PUBLIC_BASE_URL || `${forwardedProto}://${hostHeader}`; + + let body: { messages: UIMessage[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + const { messages } = body; + + // Guard against empty messages (used by the API key check) + if (!messages || messages.length === 0) { + return Response.json({ status: "ok" }); + } + + let modelMessages; + try { + modelMessages = await convertToModelMessages(messages); + } catch (err) { + console.error("[chat] Message conversion error:", err); + return Response.json( + { error: "Failed to process messages" }, + { status: 400 }, + ); + } + + try { + const result = streamText({ + model: anthropic(CHAT_MODEL), + system: SYSTEM_PROMPT, + messages: modelMessages, + tools: { + getStationData: tool({ + description: + "Get current readings and metadata for a specific monitoring station by ID (e.g. ANA-001, WB-001, GI-001)", + inputSchema: jsonSchema<{ stationId: string }>({ + type: "object", + properties: { + stationId: { + type: "string", + description: + "The station ID, e.g. ANA-001, WB-001, HR-001, GI-001", + }, + }, + required: ["stationId"], + }), + execute: async ({ stationId }) => { + try { + const res = await fetch(`${baseUrl}/api/stations`); + if (!res.ok) return { error: "Failed to fetch stations" }; + const data = await res.json(); + const station = data.stations?.find( + (s: Record) => + (s.id as string).toUpperCase() === stationId.toUpperCase(), + ); + if (!station) + return { error: `Station ${stationId} not found` }; + return station; + } catch { + return { error: "Could not reach stations API" }; + } + }, + }), + getStationHistory: tool({ + description: + "Get historical water quality readings for a station. Returns time-series data for trend analysis.", + inputSchema: jsonSchema<{ stationId: string; limit?: number }>({ + type: "object", + properties: { + stationId: { + type: "string", + description: "The station ID", + }, + limit: { + type: "number", + description: + "Max number of readings to return (default 50)", + }, + }, + required: ["stationId"], + }), + execute: async ({ stationId, limit }) => { + try { + const url = `${baseUrl}/api/stations/${stationId}/history?limit=${limit || 50}`; + const res = await fetch(url); + if (!res.ok) return { error: "Failed to fetch history" }; + return await res.json(); + } catch { + return { error: "Could not reach history API" }; + } + }, + }), + listAllStations: tool({ + description: + "List all 12 monitoring stations with their current status, type, and latest readings.", + inputSchema: jsonSchema>({ + type: "object", + properties: {}, + }), + execute: async () => { + try { + const res = await fetch(`${baseUrl}/api/stations`); + if (!res.ok) return { error: "Failed to fetch stations" }; + return await res.json(); + } catch { + return { error: "Could not reach stations API" }; + } + }, + }), + }, + stopWhen: stepCountIs(3), + maxOutputTokens: 4096, + temperature: 0.3, + onError: ({ error }) => { + console.error("[chat] Stream error during generation:", error); + }, + onFinish: ({ usage }) => { + if (usage) { + const input = usage.inputTokens ?? 0; + const output = usage.outputTokens ?? 0; + console.log( + `[chat] Token usage — input: ${input}, output: ${output}, total: ${input + output}`, + ); + } + }, + }); + + return result.toUIMessageStreamResponse(); + } catch (err) { + const message = + err instanceof Error ? err.message : "An unexpected error occurred"; + // Try to extract API error details + const details = + err && typeof err === "object" && "cause" in err + ? String((err as { cause: unknown }).cause) + : undefined; + console.error("[chat] Stream error:", message, details || ""); + return Response.json( + { error: details || message }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts new file mode 100644 index 0000000..65e18df --- /dev/null +++ b/src/app/api/export/route.ts @@ -0,0 +1,98 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +function buildCitation(stationId: string | null, rowCount: number): { + text: string; + dataset: string; + publisher: string; + accessed: string; + url: string; +} { + const now = new Date().toISOString(); + const dateStr = now.split("T")[0]; + return { + text: `UDC Water Resources Research Institute. (2026). ${ + stationId ? `Station ${stationId} Water Quality Data` : "Anacostia Watershed Water Quality Data" + } [Dataset]. University of the District of Columbia CAUSES. Accessed ${dateStr}.`, + dataset: stationId + ? `UDC WRRI Station ${stationId} Water Quality Readings` + : "UDC WRRI Anacostia Watershed Water Quality Readings", + publisher: "University of the District of Columbia — College of Agriculture, Urban Sustainability and Environmental Sciences (CAUSES)", + accessed: now, + url: stationId + ? `/api/export?format=json&station=${stationId}` + : "/api/export?format=json", + }; +} + +export async function GET(request: NextRequest) { + const db = await getDbClient(); + const searchParams = request.nextUrl.searchParams; + const format = searchParams.get("format") || "json"; + const stationId = searchParams.get("station"); + + let query = ` + SELECT + r.station_id, s.name AS station_name, s.type AS station_type, + r.timestamp, r.temperature, r.dissolved_oxygen, r.ph, + r.turbidity, r.conductivity, r.ecoli_count, r.nitrate_n, r.phosphorus, r.source + FROM readings r + JOIN stations s ON s.id = r.station_id + `; + const params: unknown[] = []; + + if (stationId) { + query += " WHERE r.station_id = ?"; + params.push(stationId); + } + query += " ORDER BY r.timestamp ASC"; + + const { rows } = await db.query(query, params); + + const citation = buildCitation(stationId, rows.length); + + if (format === "csv") { + // Citation header block for CSV + const citationLines = [ + `# Citation: ${citation.text}`, + `# Dataset: ${citation.dataset}`, + `# Publisher: ${citation.publisher}`, + `# Exported: ${citation.accessed}`, + `# Records: ${rows.length}`, + `# Data sources: ${[...new Set(rows.map((r) => r.source).filter(Boolean))].join(", ") || "N/A"}`, + "#", + ]; + + const headers = [ + "station_id", "station_name", "station_type", "timestamp", + "temperature", "dissolved_oxygen", "ph", "turbidity", + "conductivity", "ecoli_count", "nitrate_n", "phosphorus", "source", + ]; + const csvLines = [...citationLines, headers.join(",")]; + for (const row of rows) { + csvLines.push(headers.map((h) => { + const val = row[h]; + if (val === null || val === undefined) return ""; + const str = String(val); + return str.includes(",") ? `"${str}"` : str; + }).join(",")); + } + + return new NextResponse(csvLines.join("\n"), { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename=udc-water-data${stationId ? `-${stationId}` : ""}.csv`, + }, + }); + } + + return NextResponse.json({ + citation, + exported_at: citation.accessed, + count: rows.length, + sources: [...new Set(rows.map((r) => r.source).filter(Boolean))], + data: rows, + }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..b696cdd --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + let dbStatus = "unknown"; + let stationCount = 0; + + try { + const db = await getDbClient(); + const { rows } = await db.query("SELECT COUNT(*) as count FROM stations"); + stationCount = Number(rows[0]?.count ?? 0); + dbStatus = stationCount > 0 ? "connected" : "empty"; + } catch (err) { + dbStatus = `error: ${err instanceof Error ? err.message : String(err)}`; + } + + const isHealthy = dbStatus === "connected"; + + return NextResponse.json( + { + status: isHealthy ? "healthy" : "degraded", + timestamp: new Date().toISOString(), + version: process.env.npm_package_version || "1.0.0", + uptime: process.uptime(), + database: { + status: dbStatus, + stations: stationCount, + provider: process.env.DATABASE_URL ? "neon-postgresql" : "sqlite", + }, + environment: process.env.VERCEL ? "vercel" : "local", + }, + { status: isHealthy ? 200 : 503 } + ); +} diff --git a/src/app/api/ingest/route.ts b/src/app/api/ingest/route.ts new file mode 100644 index 0000000..20223cc --- /dev/null +++ b/src/app/api/ingest/route.ts @@ -0,0 +1,369 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; +import { logger } from "@/lib/logger"; + +// --------------------------------------------------------------------------- +// Data validation — reject physically impossible readings +// --------------------------------------------------------------------------- +interface ReadingValues { + temperature?: number | null; + dissolved_oxygen?: number | null; + ph?: number | null; + turbidity?: number | null; + conductivity?: number | null; + ecoli_count?: number | null; + nitrate_n?: number | null; + phosphorus?: number | null; +} + +const VALID_RANGES: Record = { + temperature: { min: -5, max: 45, label: "Temperature (°C)" }, + dissolved_oxygen: { min: 0, max: 20, label: "Dissolved Oxygen (mg/L)" }, + ph: { min: 0, max: 14, label: "pH" }, + turbidity: { min: 0, max: 4000, label: "Turbidity (NTU)" }, + conductivity: { min: 0, max: 10000, label: "Conductivity (µS/cm)" }, + ecoli_count: { min: 0, max: 100000, label: "E. coli (CFU/100mL)" }, + nitrate_n: { min: 0, max: 100, label: "Nitrate-N (mg/L)" }, + phosphorus: { min: 0, max: 50, label: "Phosphorus (mg/L)" }, +}; + +function validateReading(values: ReadingValues): { valid: ReadingValues; warnings: string[] } { + const valid: ReadingValues = { ...values }; + const warnings: string[] = []; + + for (const [field, range] of Object.entries(VALID_RANGES)) { + const val = valid[field as keyof ReadingValues]; + if (val == null) continue; + if (typeof val !== "number" || isNaN(val) || val < range.min || val > range.max) { + warnings.push(`${range.label}: ${val} out of range [${range.min}, ${range.max}] — rejected`); + (valid as Record)[field] = null; + } + } + + return { valid, warnings }; +} + +// --------------------------------------------------------------------------- +// USGS NWIS integration +// --------------------------------------------------------------------------- +const USGS_PARAMS: Record = { + "00010": "temperature", // Water temperature (°C) + "00300": "dissolved_oxygen", // Dissolved oxygen (mg/L) + "00400": "ph", // pH + "63680": "turbidity", // Turbidity (NTU) + "00095": "conductivity", // Specific conductance (µS/cm) +}; + +// Active USGS sites with water-quality sensors in the DC/Anacostia watershed +// See: https://www.usgs.gov/centers/md-de-dc-water/anacostia-water-quality-monitoring-project +const USGS_SITES = [ + { usgs: "01651000", stationId: "ANA-001" }, // NW Branch Anacostia nr Hyattsville, MD + { usgs: "01649500", stationId: "ANA-002" }, // NE Branch Anacostia at Riverdale, MD (active WQ) + { usgs: "01651827", stationId: "ANA-003" }, // Anacostia River nr Buzzard Point at Washington, DC + { usgs: "01651750", stationId: "ANA-004" }, // Anacostia River at Washington, DC (near Anacostia Park) + { usgs: "01646500", stationId: "PB-001" }, // Potomac River at Little Falls (closest WQ gauge) + { usgs: "01651800", stationId: "WB-001" }, // Watts Branch at Minnesota Ave Bridge + { usgs: "01651770", stationId: "HR-001" }, // Hickey Run at National Arboretum +]; + +interface USGSTimeSeriesValue { + value: string; + dateTime: string; +} + +interface USGSTimeSeries { + variable: { variableCode: Array<{ value: string }> }; + values: Array<{ value: USGSTimeSeriesValue[] }>; +} + +interface USGSResponse { + value?: { + timeSeries?: USGSTimeSeries[]; + }; +} + +async function ingestUSGS(): Promise<{ count: number; errors: string[]; validationWarnings: string[] }> { + const db = await getDbClient(); + const errors: string[] = []; + const validationWarnings: string[] = []; + let totalCount = 0; + + for (const site of USGS_SITES) { + const paramCodes = Object.keys(USGS_PARAMS).join(","); + const url = `https://waterservices.usgs.gov/nwis/iv/?format=json&sites=${site.usgs}¶meterCd=${paramCodes}&period=P1D`; + + try { + const response = await fetch(url); + if (!response.ok) { + errors.push(`USGS ${site.usgs}: HTTP ${response.status}`); + continue; + } + + const data: USGSResponse = await response.json(); + const timeSeries = data?.value?.timeSeries; + if (!timeSeries || timeSeries.length === 0) { + logger.info(`USGS ${site.usgs}: no time series returned (site may lack WQ sensors)`); + continue; + } + + // Group values by timestamp + const readingsByTime: Record> = {}; + + for (const series of timeSeries) { + const paramCode = series.variable?.variableCode?.[0]?.value; + const dbField = paramCode ? USGS_PARAMS[paramCode] : undefined; + if (!dbField) continue; + + for (const val of series.values?.[0]?.value ?? []) { + if (!val.value || val.value === "-999999") continue; + const ts = val.dateTime; + if (!readingsByTime[ts]) readingsByTime[ts] = {}; + readingsByTime[ts][dbField] = parseFloat(val.value); + } + } + + // Insert grouped readings with validation + let count = 0; + for (const [timestamp, values] of Object.entries(readingsByTime)) { + const { valid, warnings } = validateReading(values as ReadingValues); + if (warnings.length > 0) { + validationWarnings.push(...warnings.map((w) => `USGS ${site.usgs} @ ${timestamp}: ${w}`)); + } + + await db.query( + `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, conductivity, source) + VALUES (?, ?, ?, ?, ?, ?, ?, 'usgs')`, + [ + site.stationId, + timestamp, + valid.temperature ?? null, + valid.dissolved_oxygen ?? null, + valid.ph ?? null, + valid.turbidity ?? null, + valid.conductivity ?? null, + ] + ); + count++; + } + + totalCount += count; + logger.info(`USGS ingest: ${count} readings from site ${site.usgs}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`USGS ${site.usgs}: ${msg}`); + logger.error(`USGS ingest failed for site ${site.usgs}`, { error: msg }); + } + } + + return { count: totalCount, errors, validationWarnings }; +} + +// --------------------------------------------------------------------------- +// EPA Water Quality Portal (WQX) integration +// --------------------------------------------------------------------------- + +// EPA characteristic names mapped to our DB fields +const EPA_CHARACTERISTICS: Record = { + "Temperature, water": "temperature", + "Dissolved oxygen (DO)": "dissolved_oxygen", + "pH": "ph", + "Turbidity": "turbidity", + "Specific conductance": "conductivity", + "Escherichia coli": "ecoli_count", + "Nitrate": "nitrate_n", + "Phosphorus": "phosphorus", +}; + +// Anacostia watershed HUC-8 code and specific EPA monitoring locations +const EPA_HUC = "02070010"; // Anacostia River watershed + +// Map EPA monitoring locations to our station IDs +const EPA_STATION_MAP: Record = { + "USGS-01651000": "ANA-001", + "USGS-01649500": "ANA-002", + "USGS-01651827": "ANA-003", + "USGS-01651750": "ANA-004", + "USGS-01646500": "PB-001", + "USGS-01651800": "WB-001", + "USGS-01651770": "HR-001", +}; + +interface EPAResult { + OrganizationIdentifier?: string; + MonitoringLocationIdentifier?: string; + ActivityStartDate?: string; + ActivityStartTime?: { Time?: string }; + CharacteristicName?: string; + ResultMeasureValue?: string; + ResultMeasure?: { MeasureUnitCode?: string }; +} + +async function ingestEPA(): Promise<{ count: number; errors: string[]; validationWarnings: string[] }> { + const db = await getDbClient(); + const errors: string[] = []; + const validationWarnings: string[] = []; + let totalCount = 0; + + // Fetch last 5 years of data from the Anacostia watershed + const characteristics = Object.keys(EPA_CHARACTERISTICS).map(encodeURIComponent).join(";"); + const url = `https://www.waterqualitydata.us/data/Result/search?huc=${EPA_HUC}&characteristicName=${characteristics}&startDateLo=01-01-2020&mimeType=application/json&sorted=no&zip=no`; + + try { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(60000), // 60s timeout for large datasets + }); + + if (!response.ok) { + errors.push(`EPA WQP: HTTP ${response.status}`); + return { count: 0, errors, validationWarnings }; + } + + const results: EPAResult[] = await response.json(); + if (!Array.isArray(results) || results.length === 0) { + logger.info("EPA WQP: no results returned for Anacostia watershed"); + return { count: 0, errors, validationWarnings }; + } + + // Group results by station + timestamp + const grouped: Record> = {}; + + for (const result of results) { + const monLocId = result.MonitoringLocationIdentifier || ""; + const stationId = EPA_STATION_MAP[monLocId]; + if (!stationId) continue; // Skip stations we don't track + + const charName = result.CharacteristicName || ""; + const dbField = EPA_CHARACTERISTICS[charName]; + if (!dbField) continue; + + const date = result.ActivityStartDate || ""; + const time = result.ActivityStartTime?.Time || "12:00:00"; + const timestamp = `${date}T${time}`; + if (!date) continue; + + const value = parseFloat(result.ResultMeasureValue || ""); + if (isNaN(value)) continue; + + const key = `${stationId}::${timestamp}`; + if (!grouped[key]) { + grouped[key] = {} as Record; + } + if (!grouped[key][stationId]) { + grouped[key][stationId] = { stationId } as ReadingValues & { stationId: string }; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (grouped[key][stationId] as any)[dbField] = value; + } + + // Insert validated readings + for (const [compositeKey, stationMap] of Object.entries(grouped)) { + const timestamp = compositeKey.split("::")[1]; + for (const [, reading] of Object.entries(stationMap)) { + const { valid, warnings } = validateReading(reading); + if (warnings.length > 0) { + validationWarnings.push(...warnings.map((w) => `EPA ${reading.stationId} @ ${timestamp}: ${w}`)); + } + + await db.query( + `INSERT INTO readings (station_id, timestamp, temperature, dissolved_oxygen, ph, turbidity, conductivity, ecoli_count, nitrate_n, phosphorus, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'epa')`, + [ + reading.stationId, + timestamp, + valid.temperature ?? null, + valid.dissolved_oxygen ?? null, + valid.ph ?? null, + valid.turbidity ?? null, + valid.conductivity ?? null, + valid.ecoli_count ?? null, + valid.nitrate_n ?? null, + valid.phosphorus ?? null, + ] + ); + totalCount++; + } + } + + logger.info(`EPA WQP ingest: ${totalCount} readings from Anacostia watershed`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`EPA WQP: ${msg}`); + logger.error("EPA WQP ingest failed", { error: msg }); + } + + return { count: totalCount, errors, validationWarnings }; +} + +// --------------------------------------------------------------------------- +// API handlers +// --------------------------------------------------------------------------- + +// GET handler for Vercel Cron — authenticated via CRON_SECRET header +export async function GET(request: NextRequest) { + const cronSecret = process.env.CRON_SECRET; + if (cronSecret && request.headers.get("authorization") !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Delegate to the shared ingest logic + return runIngest(request); +} + +// POST handler for manual triggers — authenticated via INGEST_API_KEY +export async function POST(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + const apiKey = process.env.INGEST_API_KEY; + if (!apiKey && process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "INGEST_API_KEY not configured" }, { status: 503 }); + } + if (apiKey && authHeader !== `Bearer ${apiKey}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return runIngest(request); +} + +async function runIngest(request: NextRequest) { + const db = await getDbClient(); + const searchParams = request.nextUrl.searchParams; + const source = searchParams.get("source") || "usgs"; + + try { + let result: { count: number; errors: string[]; validationWarnings: string[] }; + + if (source === "usgs") { + result = await ingestUSGS(); + } else if (source === "epa") { + result = await ingestEPA(); + } else { + return NextResponse.json({ error: `Unknown source: ${source}. Supported: usgs, epa` }, { status: 400 }); + } + + const status = result.errors.length === 0 ? "success" : "error"; + const nowFn = process.env.DATABASE_URL ? "NOW()" : "datetime('now')"; + await db.query( + `INSERT INTO ingestion_log (source, status, records_count, error_message, completed_at) + VALUES (?, ?, ?, ?, ${nowFn})`, + [source, status, result.count, result.errors.join("; ") || null] + ); + + return NextResponse.json({ + source, + status, + records_ingested: result.count, + errors: result.errors, + validation_warnings: result.validationWarnings, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const nowFn2 = process.env.DATABASE_URL ? "NOW()" : "datetime('now')"; + await db.query( + `INSERT INTO ingestion_log (source, status, records_count, error_message, completed_at) + VALUES (?, ?, ?, ?, ${nowFn2})`, + [source, "error", 0, msg] + ).catch(() => {}); // Don't fail if logging fails + logger.error("Ingestion failed", { source, error: msg }); + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/ingestion-log/route.ts b/src/app/api/ingestion-log/route.ts new file mode 100644 index 0000000..7968b0b --- /dev/null +++ b/src/app/api/ingestion-log/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +export async function GET(request: NextRequest) { + const db = await getDbClient(); + const searchParams = request.nextUrl.searchParams; + const limit = Math.min(parseInt(searchParams.get("limit") || "50"), 200); + + const { rows } = await db.query( + `SELECT id, source, status, records_count, error_message, started_at, completed_at + FROM ingestion_log + ORDER BY started_at DESC + LIMIT ?`, + [limit] + ); + + return NextResponse.json({ + count: rows.length, + logs: rows, + }); +} diff --git a/src/app/api/stations/[id]/history/route.ts b/src/app/api/stations/[id]/history/route.ts new file mode 100644 index 0000000..fb6f5e0 --- /dev/null +++ b/src/app/api/stations/[id]/history/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const db = await getDbClient(); + + const { rows: stationRows } = await db.query( + "SELECT * FROM stations WHERE id = ?", + [id] + ); + if (stationRows.length === 0) { + return NextResponse.json({ error: "Station not found" }, { status: 404 }); + } + + const searchParams = request.nextUrl.searchParams; + const limit = Math.min(parseInt(searchParams.get("limit") || "365"), 1000); + const from = searchParams.get("from"); + const to = searchParams.get("to"); + + let query = ` + SELECT timestamp, temperature, dissolved_oxygen, ph, turbidity, + conductivity, ecoli_count, nitrate_n, phosphorus, source + FROM readings + WHERE station_id = ? + `; + const queryParams: unknown[] = [id]; + + if (from) { + query += " AND timestamp >= ?"; + queryParams.push(from); + } + if (to) { + query += " AND timestamp <= ?"; + queryParams.push(to); + } + + query += " ORDER BY timestamp ASC LIMIT ?"; + queryParams.push(limit); + + const { rows: readings } = await db.query(query, queryParams); + + const data = readings.map((r) => ({ + timestamp: r.timestamp, + temperature: r.temperature, + dissolvedOxygen: r.dissolved_oxygen, + pH: r.ph, + turbidity: r.turbidity, + conductivity: r.conductivity, + eColiCount: r.ecoli_count, + nitrateN: r.nitrate_n, + phosphorus: r.phosphorus, + source: r.source, + })); + + return NextResponse.json({ + stationId: id, + count: data.length, + data, + }); +} diff --git a/src/app/api/stations/route.ts b/src/app/api/stations/route.ts new file mode 100644 index 0000000..d7345fb --- /dev/null +++ b/src/app/api/stations/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getDbClient } from "@/lib/db"; + +// Force dynamic rendering so fresh data is always served after ingestion +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const db = await getDbClient(); + + const { rows: stations } = await db.query(` + SELECT + s.*, + r.timestamp AS last_reading_time, + r.temperature, + r.dissolved_oxygen, + r.ph, + r.turbidity, + r.conductivity, + r.ecoli_count, + r.nitrate_n, + r.phosphorus, + r.source AS last_reading_source + FROM stations s + LEFT JOIN readings r ON r.station_id = s.id + AND r.timestamp = (SELECT MAX(timestamp) FROM readings WHERE station_id = s.id) + ORDER BY s.name + `); + + const result = stations.map((s) => ({ + id: s.id, + name: s.name, + position: [s.latitude, s.longitude], + type: s.type, + status: s.status, + parameters: JSON.parse(s.parameters as string), + lastReading: s.last_reading_time + ? { + timestamp: s.last_reading_time, + temperature: s.temperature, + dissolvedOxygen: s.dissolved_oxygen, + pH: s.ph, + turbidity: s.turbidity, + conductivity: s.conductivity, + eColiCount: s.ecoli_count, + nitrateN: s.nitrate_n, + phosphorus: s.phosphorus, + source: s.last_reading_source || "seed", + } + : undefined, + })); + + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown database error"; + console.error("[/api/stations] Database error:", message); + + // Detect common better-sqlite3 native module issues + if (message.includes("better-sqlite3") || message.includes("MODULE_NOT_FOUND") || message.includes("native")) { + return NextResponse.json( + { error: "Database driver failed to load. Run: npm rebuild better-sqlite3" }, + { status: 503 } + ); + } + + return NextResponse.json( + { error: "Failed to fetch stations", details: message }, + { status: 500 } + ); + } +} diff --git a/src/app/education/page.tsx b/src/app/education/page.tsx index 0a1a912..7ac7c3f 100644 --- a/src/app/education/page.tsx +++ b/src/app/education/page.tsx @@ -1,8 +1,10 @@ "use client"; +import { useState } from "react"; import Sidebar from "@/components/layout/Sidebar"; import Header from "@/components/layout/Header"; import { useTheme } from "@/context/ThemeContext"; +import Modal from "@/components/Modal"; import { GraduationCap, BookOpen, @@ -21,8 +23,18 @@ import { Leaf, Waves, AlertTriangle, + Target, + CheckCircle2, + HelpCircle, + Lightbulb, + Fish, + Thermometer, + Activity, } from "lucide-react"; +// --------------------------------------------------------------------------- +// Educational modules — enriched with learning outcomes and key takeaways +// --------------------------------------------------------------------------- const educationalModules = [ { title: "Understanding the Anacostia", @@ -40,6 +52,13 @@ const educationalModules = [ "Community cleanup efforts", ], duration: "30 min", + outcomes: [ + "Describe the Anacostia watershed geography and its 176 square-mile drainage area", + "Explain how impervious surfaces increase stormwater runoff volume and pollutant loading", + "Identify three major restoration programs (DC Clean Rivers, Anacostia Waterfront Initiative, DOEE MS4)", + "Recognize the historical role of environmental racism in the Anacostia's degradation", + ], + keyFact: "The Anacostia River drains a 176 square-mile watershed across DC, Maryland, and Virginia, serving over 800,000 residents. Over 70% of its watershed is covered by impervious surfaces.", }, { title: "Water Quality 101", @@ -57,6 +76,13 @@ const educationalModules = [ "Public health implications", ], duration: "45 min", + outcomes: [ + "Define dissolved oxygen, pH, turbidity, conductivity, and E. coli and their units", + "Explain EPA water quality criteria under the Clean Water Act Section 304(a)", + "Interpret a water quality monitoring report and identify compliance issues", + "Describe how combined sewer overflows (CSOs) affect E. coli levels and recreational safety", + ], + keyFact: "Dissolved oxygen below 5 mg/L means fish can't survive. The Anacostia frequently drops below this threshold in summer when warm temperatures reduce oxygen solubility.", }, { title: "Green Infrastructure Solutions", @@ -74,6 +100,13 @@ const educationalModules = [ "Tree cell filtration systems", ], duration: "60 min", + outcomes: [ + "Compare pollutant removal efficiency across four types of green infrastructure", + "Calculate stormwater retention volume for a simple rain garden design", + "Analyze real performance data from UDC's green infrastructure monitoring stations", + "Evaluate cost-effectiveness of green vs. grey infrastructure solutions", + ], + keyFact: "UDC's green roof research station captures 60-80% of rainfall, reducing stormwater runoff that would otherwise carry pollutants directly to the Anacostia.", }, { title: "Environmental Justice & Water", @@ -91,6 +124,13 @@ const educationalModules = [ "Policy and advocacy tools", ], duration: "50 min", + outcomes: [ + "Apply EPA's EJ framework to analyze disproportionate environmental burdens in DC Wards 7 and 8", + "Interpret ward-level CSO, impervious surface, and green space data from this dashboard", + "Describe the relationship between income, race, flood risk, and water quality in DC", + "Identify community-driven policy tools for environmental justice advocacy", + ], + keyFact: "DC's Ward 8 has the highest flood risk, fewest green spaces (28%), and most CSO overflow events (45/year) — yet has the least political representation in environmental policy.", }, { title: "Stormwater Data Analysis", @@ -108,6 +148,13 @@ const educationalModules = [ "Publishing research findings", ], duration: "90 min", + outcomes: [ + "Fetch and clean water quality data from the UDC dashboard API using Python or R", + "Perform time-series analysis to identify seasonal patterns and trends", + "Apply EPA compliance threshold checks programmatically to large datasets", + "Create publication-quality charts using matplotlib or ggplot2", + ], + keyFact: "Download our Python and R templates from the Analysis Templates section below — they connect directly to this dashboard's API with real data.", }, { title: "Emerging Contaminants", @@ -125,6 +172,63 @@ const educationalModules = [ "Risk characterization methods", ], duration: "75 min", + outcomes: [ + "Explain the bioaccumulation pathway and health risks of PFAS 'forever chemicals'", + "Describe analytical methods for detecting pharmaceuticals at parts-per-trillion levels", + "Evaluate microplastic sampling and identification protocols for urban waterways", + "Apply EPA's four-step risk assessment framework to emerging contaminant data", + ], + keyFact: "PFAS compounds have been detected in the Potomac and Anacostia at concentrations exceeding EPA's 2024 health advisory of 4 parts per trillion for PFOA.", + }, +]; + +// --------------------------------------------------------------------------- +// Interactive exercises — "What does this reading mean?" +// --------------------------------------------------------------------------- +const interactiveExercises = [ + { + parameter: "Dissolved Oxygen", + icon: Droplets, + color: "text-blue-400", + bgColor: "bg-blue-500/10", + value: "4.0 mg/L", + question: "A station reports DO of 4.0 mg/L. What does this mean for fish survival?", + answer: "This reading is below the EPA minimum of 5.0 mg/L for aquatic life support. Fish species like largemouth bass need at least 5 mg/L — at 4.0, sensitive species begin to suffocate. Catfish and carp can tolerate down to 3 mg/L temporarily, but prolonged exposure causes die-offs. This reading would trigger a compliance alert on the dashboard.", + threshold: "EPA minimum: 5.0 mg/L", + severity: "warning" as const, + }, + { + parameter: "E. coli", + icon: AlertTriangle, + color: "text-red-400", + bgColor: "bg-red-500/10", + value: "850 CFU/100mL", + question: "E. coli reads 850 CFU/100mL after a rainstorm. Is it safe to wade in?", + answer: "No. The EPA recreational water quality criterion is 410 CFU/100mL for a single sample. At 850, the risk of gastrointestinal illness from recreational contact (swimming, wading, kayaking) is significantly elevated. This likely resulted from a combined sewer overflow during the storm. Wait at least 48 hours after heavy rain for levels to return to baseline.", + threshold: "EPA limit: 410 CFU/100mL", + severity: "danger" as const, + }, + { + parameter: "pH Level", + icon: Activity, + color: "text-emerald-400", + bgColor: "bg-emerald-500/10", + value: "7.2", + question: "The Anacostia reads pH 7.2 today. Is this healthy?", + answer: "Yes! pH 7.2 is within the EPA optimal range of 6.5-9.0 for freshwater aquatic life. It's slightly above neutral (7.0), which is typical for the Anacostia. This pH supports diverse biological communities, allows nutrients to remain bioavailable, and keeps metals like aluminum in their less toxic forms.", + threshold: "EPA range: 6.5–9.0", + severity: "good" as const, + }, + { + parameter: "Water Temperature", + icon: Thermometer, + color: "text-cyan-400", + bgColor: "bg-cyan-500/10", + value: "29.5°C", + question: "August readings show 29.5°C (85°F). Should we be concerned?", + answer: "This is approaching the warm-water aquatic life limit of 32°C. At 29.5°C, dissolved oxygen capacity drops to about 7.5 mg/L (compared to 11 mg/L at 10°C), stressing fish. Thermal pollution from hot pavement runoff during summer storms can push temperatures even higher. Green infrastructure helps by slowing and cooling runoff before it enters the river.", + threshold: "EPA warm-water limit: 32°C", + severity: "warning" as const, }, ]; @@ -134,28 +238,28 @@ const communityEvents = [ date: "April 12, 2026", location: "Anacostia Park", type: "Community", - description: "Join UDC students and community members for the annual spring cleanup along the Anacostia River.", + description: "Join UDC students and community members for the annual spring cleanup along the Anacostia River. Gloves, bags, and refreshments provided. All ages welcome.", }, { title: "Water Quality Workshop", date: "April 25, 2026", location: "UDC Van Ness Campus", type: "Workshop", - description: "Hands-on workshop learning to use water quality testing kits. Open to DC residents.", + description: "Hands-on workshop learning to use water quality testing kits — measure pH, dissolved oxygen, and turbidity. Ideal for students and DC residents. No experience needed.", }, { title: "WRRI Research Symposium", date: "May 8, 2026", location: "UDC Auditorium", type: "Academic", - description: "Annual presentation of WRRI research findings. Faculty, students, and public welcome.", + description: "Annual presentation of WRRI research findings including Anacostia restoration, green infrastructure performance, and emerging contaminant detection. Keynote by EPA Region 3.", }, { title: "Green Infrastructure Tour", date: "May 15, 2026", location: "Multiple UDC Sites", type: "Tour", - description: "Guided tour of UDC's green infrastructure installations including green roofs and rain gardens.", + description: "Guided tour of UDC's green infrastructure installations: green roofs at Van Ness, rain gardens at Community College, and bioswales at research sites. See real monitoring equipment in action.", }, ]; @@ -165,43 +269,63 @@ const openDatasets = [ format: "CSV / JSON", size: "12.4 MB", records: "45,000+", - description: "Historical water quality measurements from all Anacostia monitoring stations.", + description: "Historical water quality measurements from all Anacostia monitoring stations. Includes temperature, DO, pH, turbidity, conductivity, E. coli, nutrients.", + apiLink: "/api/export?format=csv", }, { name: "Stormwater BMP Performance", format: "CSV", size: "3.8 MB", records: "12,000+", - description: "Green infrastructure performance data including retention rates and water quality improvements.", + description: "Green infrastructure performance data including retention rates and water quality improvements from UDC's BMP monitoring network.", + apiLink: "/api/export?format=csv&station=GI-001", }, { name: "DC Ward Environmental Data", format: "GeoJSON / CSV", size: "8.2 MB", records: "2,400+", - description: "Ward-level environmental justice indicators including CSO events, impervious surfaces, and green space.", + description: "Ward-level environmental justice indicators: CSO events, impervious surface coverage, green space access, flood risk, and demographic data.", + apiLink: "", }, { name: "UDC Green Roof Monitoring", format: "CSV", size: "5.1 MB", records: "18,000+", - description: "Detailed measurements from UDC's experimental green roof installations.", + description: "Detailed measurements from UDC's experimental green roof installations — rainfall capture, runoff volume, influent/effluent quality, and temperature buffering.", + apiLink: "/api/export?format=csv&station=GI-002", }, ]; export default function EducationPage() { const { resolvedTheme } = useTheme(); const isDark = resolvedTheme === "dark"; + const [levelFilter, setLevelFilter] = useState("All"); + const [activeModule, setActiveModule] = useState<(typeof educationalModules)[0] | null>(null); + const [revealedExercises, setRevealedExercises] = useState>(new Set()); + + const filteredModules = levelFilter === "All" + ? educationalModules + : educationalModules.filter((m) => m.level === levelFilter); + + function toggleExercise(index: number) { + setRevealedExercises((prev) => { + const next = new Set(prev); + if (next.has(index)) next.delete(index); + else next.add(index); + return next; + }); + } return (
-
+
-
+
{/* Page Header */} -
Learn About DC's Water Resources -

+

Educational resources for DC residents, UDC students, and faculty. From introductory water quality concepts to advanced data analysis — empowering informed stewardship of our waterways. @@ -224,7 +348,7 @@ export default function EducationPage() {

- {/* Audience Selector */} + {/* Audience Selector — now scrolls to relevant sections */}
{[ { @@ -234,6 +358,8 @@ export default function EducationPage() { bgColor: "bg-blue-500/10", borderColor: "border-blue-500/20", description: "Accessible information about water quality, safety, and how to get involved in watershed protection.", + action: () => { setLevelFilter("Beginner"); document.getElementById("modules")?.scrollIntoView({ behavior: "smooth" }); }, + cta: "Start with the basics", }, { title: "UDC Students", @@ -241,7 +367,9 @@ export default function EducationPage() { color: "text-green-400", bgColor: "bg-green-500/10", borderColor: "border-green-500/20", - description: "Course materials, datasets for analysis, and research opportunities in environmental science.", + description: "Course materials, real datasets for analysis, and downloadable Python/R templates for coursework.", + action: () => { setLevelFilter("Intermediate"); document.getElementById("modules")?.scrollIntoView({ behavior: "smooth" }); }, + cta: "Explore course materials", }, { title: "Faculty & Researchers", @@ -249,43 +377,123 @@ export default function EducationPage() { color: "text-purple-400", bgColor: "bg-purple-500/10", borderColor: "border-purple-500/20", - description: "Open datasets, API access, collaboration tools, and publication resources for water research.", + description: "Open datasets, API access for programmatic data retrieval, and publication resources for water research.", + action: () => document.getElementById("resources")?.scrollIntoView({ behavior: "smooth" }), + cta: "Access open data & API", }, ].map((audience) => ( -

{audience.title}

-

{audience.description}

+

{audience.description}

- Explore + {audience.cta}
-
+ ))}
- {/* Educational Modules */} + {/* Interactive Exercises */}
-
+
+ +

What Does This Reading Mean?

+
+

+ Interactive exercises — click to reveal the answer and learn how to interpret real water quality data +

+
+ {interactiveExercises.map((ex, i) => { + const revealed = revealedExercises.has(i); + const Icon = ex.icon; + return ( +
+ + {revealed && ( +
+
+
+ +

+ {ex.answer} +

+
+
+
+ )} +
+ ); + })} +
+
+ + {/* Educational Modules */} +
+

Learning Modules

-

+

Self-paced educational content for all knowledge levels

-
+
{["All", "Beginner", "Intermediate", "Advanced"].map((level) => (
- {educationalModules.map((module) => ( -
( + ))}
+ {/* Module Detail Modal */} + {activeModule && ( + setActiveModule(null)} + title={activeModule.title} + subtitle={`${activeModule.level} · ${activeModule.duration}`} + icon={} + > +
+

+ {activeModule.description} +

+ + {/* Key Fact */} +
+
+ +

+ Key fact: {activeModule.keyFact} +

+
+
+ + {/* Topics Covered */} +
+

Topics Covered

+
+ {activeModule.topics.map((topic) => ( +
+ + {topic} +
+ ))} +
+
+ + {/* Learning Outcomes */} +
+

+ + Learning Outcomes +

+
+ {activeModule.outcomes.map((outcome, i) => ( +
+ + {outcome} +
+ ))} +
+
+ + {/* Related resources */} + +
+
+ )} + {/* Community Events */}

Community Events

-

+

Upcoming opportunities to engage with DC water resources

{communityEvents.map((event) => (
@@ -357,8 +640,8 @@ export default function EducationPage() {

{event.title}

-

{event.description}

-
+

{event.description}

+
{event.date} @@ -373,30 +656,96 @@ export default function EducationPage() {
+ {/* Analysis Templates */} +
+

Analysis Templates

+

+ Download ready-to-use scripts that fetch real data from the dashboard API +

+
+ {[ + { + name: "Python Analysis Template", + file: "/templates/udc_water_analysis.py", + lang: "Python", + color: "text-blue-400", + bg: "bg-blue-500/10", + borderColor: "border-blue-500/20", + description: "Pandas + Matplotlib script: fetches station data, calculates summary stats, checks EPA compliance, and generates publication-ready charts.", + requires: "pip install requests pandas matplotlib", + }, + { + name: "R Analysis Template", + file: "/templates/udc_water_analysis.R", + lang: "R", + color: "text-green-400", + bg: "bg-green-500/10", + borderColor: "border-green-500/20", + description: "tidyverse + ggplot2 script: fetches station data, performs statistical analysis, EPA compliance checks, and multi-parameter visualizations.", + requires: 'install.packages(c("httr", "jsonlite", "ggplot2", "dplyr", "tidyr"))', + }, + ].map((tmpl) => ( +
+ +

{tmpl.name}

+

{tmpl.description}

+ + {tmpl.requires} + +
+ ))} +
+
+ {/* Open Data */}

Open Data Portal

-

- Download research datasets for analysis and education +

+ Download research datasets for analysis and education. Data is available via the API or as direct downloads.

{openDatasets.map((dataset) => (
- + {dataset.apiLink ? ( + + + Download + + ) : ( + + Coming soon + + )}

{dataset.name}

-

{dataset.description}

-
+

{dataset.description}

+
Format: {dataset.format} Size: {dataset.size} Records: {dataset.records} @@ -405,6 +754,32 @@ export default function EducationPage() { ))}
+ + {/* API Access Banner */} +
+
+ +
+

Programmatic API Access

+

+ All data is available via REST API for integration into your own applications, + research scripts, or community tools. No authentication required for read access. +

+
+
GET /api/stations — All stations with latest readings
+
GET /api/stations/:id/history — Historical data for a station
+
GET /api/export?format=csv — Full dataset export
+
GET /api/export?format=json&station=ANA-001 — Station-specific JSON
+
+ + View full API documentation + +
+
+
diff --git a/src/app/globals.css b/src/app/globals.css index 8532360..b0b2415 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -283,3 +283,12 @@ html.light .ward-label { html.light .ward-label::before { display: none !important; } + +/* Mobile sidebar slide-in animation */ +@keyframes slide-in-left { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +.animate-slide-in-left { + animation: slide-in-left 0.25s ease-out; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a1a9687..a961595 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,11 +1,15 @@ import type { Metadata } from "next"; import { ThemeProvider } from "@/context/ThemeContext"; +import { SidebarProvider } from "@/context/SidebarContext"; +import { LanguageProvider } from "@/context/LanguageContext"; +import ErrorBoundary from "@/components/ErrorBoundary"; +import ResearchAssistantWrapper from "@/components/ai/ResearchAssistantWrapper"; import "./globals.css"; export const metadata: Metadata = { title: "UDC Water Resources Dashboard | Data Integration, Analysis & Visualization", description: - "University of the District of Columbia CAUSES/WRRI interactive water resources dashboard for the Anacostia River watershed. Real-time monitoring, research data, and environmental education for DC communities.", + "University of the District of Columbia CAUSES/WRRI interactive water resources dashboard for the Anacostia River watershed. Monitoring, research data, and environmental education for DC communities.", keywords: [ "UDC", "University of the District of Columbia", @@ -40,8 +44,22 @@ export default function RootLayout({ /> + {/* Skip-to-content link for keyboard/screen reader users (WCAG 2.1 AA §2.4.1) */} + + Skip to main content + - {children} + + + + {children} + + + + diff --git a/src/app/methodology/page.tsx b/src/app/methodology/page.tsx new file mode 100644 index 0000000..44ca64c --- /dev/null +++ b/src/app/methodology/page.tsx @@ -0,0 +1,568 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Sidebar from "@/components/layout/Sidebar"; +import Header from "@/components/layout/Header"; +import { useTheme } from "@/context/ThemeContext"; +import { + BookOpen, + Database, + Shield, + Clock, + CheckCircle2, + AlertCircle, + FileText, + Activity, + Beaker, + Scale, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Data Dictionary — every parameter we collect +// --------------------------------------------------------------------------- +const DATA_DICTIONARY = [ + { + parameter: "Water Temperature", + field: "temperature", + unit: "°C", + method: "Thermistor probe (YSI 6600)", + range: "-5 to 45", + epaStandard: "Varies by water body class; generally ≤32°C for warm-water aquatic life", + detectionLimit: "0.01°C", + description: "Temperature of water at sampling depth. Affects dissolved oxygen capacity, metabolic rates, and aquatic organism survival.", + }, + { + parameter: "Dissolved Oxygen", + field: "dissolved_oxygen", + unit: "mg/L", + method: "Optical DO sensor (luminescent)", + range: "0 to 20", + epaStandard: "≥5.0 mg/L for aquatic life support (CWA §304(a))", + detectionLimit: "0.1 mg/L", + description: "Concentration of molecular oxygen dissolved in water. Critical for fish and macroinvertebrate survival. Levels below 5 mg/L indicate stress; below 2 mg/L is hypoxic.", + }, + { + parameter: "pH", + field: "ph", + unit: "Standard units", + method: "Glass electrode potentiometry", + range: "0 to 14", + epaStandard: "6.5–9.0 for freshwater aquatic life (EPA Gold Book)", + detectionLimit: "0.01 SU", + description: "Measure of hydrogen ion concentration (acidity/alkalinity). Affects nutrient availability, metal toxicity, and biological processes.", + }, + { + parameter: "Turbidity", + field: "turbidity", + unit: "NTU", + method: "Nephelometric (90° scattered light)", + range: "0 to 4,000", + epaStandard: "Narrative: shall not exceed levels detrimental to aquatic life. DC DOEE: ≤50 NTU typical reference", + detectionLimit: "0.1 NTU", + description: "Measure of water clarity caused by suspended particles. High turbidity reduces light penetration, affecting photosynthesis and can indicate erosion or pollution.", + }, + { + parameter: "Specific Conductance", + field: "conductivity", + unit: "µS/cm", + method: "4-electrode conductivity cell, temperature-compensated to 25°C", + range: "0 to 10,000", + epaStandard: "No federal numeric standard; DC reference: 150–500 µS/cm typical freshwater", + detectionLimit: "1 µS/cm", + description: "Ability of water to conduct electrical current, indicating total dissolved ion concentration. Elevated values may indicate road salt, sewage, or industrial discharge.", + }, + { + parameter: "E. coli", + field: "ecoli_count", + unit: "CFU/100mL", + method: "Membrane filtration / Colilert Quanti-Tray (IDEXX)", + range: "0 to 100,000", + epaStandard: "≤410 CFU/100mL (single sample recreational contact, EPA 2012 RWQC)", + detectionLimit: "1 CFU/100mL", + description: "Fecal indicator bacteria. Indicates potential presence of pathogens from sewage or animal waste. Primary indicator for recreational water safety.", + }, + { + parameter: "Nitrate-Nitrogen", + field: "nitrate_n", + unit: "mg/L", + method: "Ion chromatography / cadmium reduction", + range: "0 to 100", + epaStandard: "10 mg/L (drinking water MCL, EPA 40 CFR 141)", + detectionLimit: "0.01 mg/L", + description: "Inorganic nitrogen form readily used by algae and plants. Excess leads to eutrophication, algal blooms, and oxygen depletion.", + }, + { + parameter: "Total Phosphorus", + field: "phosphorus", + unit: "mg/L", + method: "Ascorbic acid colorimetry (SM 4500-P E)", + range: "0 to 50", + epaStandard: "0.1 mg/L (EPA recommended for streams; Anacostia TMDL target)", + detectionLimit: "0.005 mg/L", + description: "Limiting nutrient for freshwater algal growth. Major contributor to eutrophication in the Anacostia. Sources include fertilizer runoff, wastewater, and erosion.", + }, +]; + +// --------------------------------------------------------------------------- +// Ingestion history types +// --------------------------------------------------------------------------- +interface IngestionLogEntry { + id: number; + source: string; + status: string; + records_count: number; + error_message: string | null; + started_at: string; + completed_at: string | null; +} + +export default function MethodologyPage() { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(true); + + const fetchLogs = useCallback(async () => { + try { + const res = await fetch("/api/ingestion-log?limit=20"); + if (res.ok) { + const data = await res.json(); + setLogs(data.logs || []); + } + } catch { + // silent — logs are supplementary + } finally { + setLogsLoading(false); + } + }, []); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + return ( +
+ +
+
+
+ {/* Page Header */} +
+
+
+ + + Methodology & Data Documentation + +
+

+ Data Methodology & Quality Assurance +

+

+ Complete documentation of sampling protocols, quality assurance procedures, parameter + definitions, and data provenance. This page enables researchers to verify, cite, and + reproduce analyses using UDC WRRI water quality data. +

+
+
+ + {/* Quick Navigation */} +
+ {[ + { label: "Data Dictionary", icon: Database, href: "#data-dictionary", color: "text-blue-400", bg: "bg-blue-500/10" }, + { label: "Sampling Protocols", icon: Beaker, href: "#sampling", color: "text-green-400", bg: "bg-green-500/10" }, + { label: "QA/QC Procedures", icon: Shield, href: "#qaqc", color: "text-purple-400", bg: "bg-purple-500/10" }, + { label: "Ingestion History", icon: Clock, href: "#history", color: "text-amber-400", bg: "bg-amber-500/10" }, + ].map((nav) => ( + +
+ +
+ {nav.label} +
+ ))} +
+ + {/* Data Sources */} +
+

Data Sources & Provenance

+

+ Every reading in the database is tagged with its source for full traceability +

+
+ {[ + { + source: "USGS NWIS", + badge: "usgs", + color: "text-blue-400", + bg: "bg-blue-500/10 border-blue-500/20", + description: "Real-time and daily values from USGS National Water Information System. Instantaneous values via the NWIS IV Web Service.", + sites: "01651000, 01649500, 01646500", + frequency: "Daily automated ingestion (06:00 UTC)", + url: "https://waterservices.usgs.gov", + }, + { + source: "EPA WQX", + badge: "epa", + color: "text-green-400", + bg: "bg-green-500/10 border-green-500/20", + description: "Historical water quality data from EPA's Water Quality Exchange portal. Provides longitudinal data back to 2020.", + sites: "Anacostia watershed (HUC 02070010)", + frequency: "On-demand ingestion", + url: "https://www.waterqualitydata.us", + }, + { + source: "Baseline / Modeled", + badge: "seed", + color: "text-slate-400", + bg: "bg-slate-500/10 border-slate-500/20", + description: "Initial dataset derived from published averages and modeled values for station commissioning. Being progressively replaced by measured data.", + sites: "All 12 stations", + frequency: "One-time seed data", + url: "", + }, + { + source: "Manual Entry", + badge: "manual", + color: "text-amber-400", + bg: "bg-amber-500/10 border-amber-500/20", + description: "Field measurements entered by WRRI researchers and trained student technicians. Includes grab samples and portable meter readings.", + sites: "As collected", + frequency: "Event-driven", + url: "", + }, + ].map((src) => ( +
+
+ + {src.badge.toUpperCase()} + + {src.source} +
+

{src.description}

+
+
Sites: {src.sites}
+
Frequency: {src.frequency}
+ {src.url && ( +
+ API: + {src.url} +
+ )} +
+
+ ))} +
+
+ + {/* Data Dictionary */} +
+
+ +

Data Dictionary

+
+

+ Complete definitions for every parameter collected, including measurement methods, valid ranges, and regulatory standards +

+
+ {DATA_DICTIONARY.map((param) => ( +
+
+
+

{param.parameter}

+ field: {param.field} +
+ + {param.unit} + +
+

{param.description}

+
+ {[ + { label: "Method", value: param.method }, + { label: "Valid Range", value: param.range }, + { label: "Detection Limit", value: param.detectionLimit }, + { label: "EPA Standard", value: param.epaStandard }, + ].map((detail) => ( +
+
{detail.label}
+
{detail.value}
+
+ ))} +
+
+ ))} +
+
+ + {/* Sampling Protocols */} +
+
+ +

Sampling Protocols

+
+

+ Standard operating procedures for field data collection +

+
+ {[ + { + title: "Continuous Monitoring Stations", + icon: Activity, + items: [ + "Multi-parameter sondes (YSI 6600 / EXO2) deployed at fixed stations", + "15-minute recording interval for temperature, DO, pH, turbidity, conductivity", + "Sensors calibrated monthly using NIST-traceable standards", + "Anti-fouling wipers activated before each measurement cycle", + "Data transmitted via cellular telemetry to USGS NWIS database", + "Backup manual readings during maintenance windows", + ], + }, + { + title: "Grab Sampling (E. coli, Nutrients)", + icon: Beaker, + items: [ + "Samples collected mid-channel at 0.3m depth (wadeable) or from bridge with weighted sampler", + "Sterile 500mL Whirl-Pak bags for bacteriological samples", + "Acid-washed HDPE bottles for nutrient analysis (pre-rinsed 3×)", + "Samples stored on ice (4°C) and transported to lab within 6 hours", + "E. coli processed within 24 hours per EPA Method 1603", + "Nutrient samples filtered (0.45µm) and preserved with H₂SO₄ for nitrogen/phosphorus", + ], + }, + { + title: "Stormwater BMP Monitoring", + icon: Scale, + items: [ + "Paired influent/effluent sampling at green infrastructure installations", + "Flow-weighted composite sampling during storm events (ISCO 6712)", + "Minimum 3 first-flush events captured per quarter", + "Runoff volume measured via calibrated flumes and pressure transducers", + "Pre/post performance metrics: pollutant removal efficiency (%)", + "Rainfall intensity recorded by co-located tipping bucket gauge", + ], + }, + { + title: "Field QC Requirements", + icon: Shield, + items: [ + "Field duplicate collected every 10th sample (≥10% frequency)", + "Equipment blank run at start of each sampling event", + "Trip blank accompanies every cooler of samples", + "Field meter calibration documented in log book before each use", + "Chain of custody form signed at each transfer point", + "GPS coordinates recorded at each sampling location (±3m accuracy)", + ], + }, + ].map((protocol) => ( +
+
+ +

{protocol.title}

+
+
    + {protocol.items.map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+ ))} +
+
+ + {/* QA/QC Procedures */} +
+
+ +

Quality Assurance / Quality Control

+
+

+ Procedures ensuring data reliability and fitness for research use +

+
+
+
+

Automated Validation (Ingest Pipeline)

+
    + {[ + "Physical range checks reject impossible values (e.g., pH > 14, negative DO)", + "USGS -999999 sentinel values filtered before storage", + "Duplicate timestamp detection per station (same source + time = skip)", + "Validation warnings logged and returned in API response", + "All rejected values recorded with reason for audit trail", + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+

Manual Review Procedures

+
    + {[ + "Monthly data review by WRRI research staff", + "Time-series plots inspected for sensor drift or fouling artifacts", + "Cross-parameter consistency checks (e.g., high temp + low DO = expected)", + "Lab duplicate RPD must be ≤25% for acceptance", + "Flagged values annotated but retained for transparency (not deleted)", + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+ + {/* Validation Ranges Quick Reference */} +
+

Validation Ranges (Automated Rejection Thresholds)

+
+ + + + {["Parameter", "Min", "Max", "Unit", "Action if Out-of-Range"].map((h) => ( + + ))} + + + + {DATA_DICTIONARY.map((p) => { + const [min, max] = p.range.replace(/,/g, "").split(" to "); + return ( + + + + + + + + ); + })} + +
{h}
{p.parameter}{min}{max}{p.unit}Value set to NULL + warning logged
+
+
+ + {/* Ingestion History */} +
+
+ +

Ingestion History

+
+

+ Log of all data ingestion events — when data was fetched, how many records were added, and any errors encountered +

+
+ {logsLoading ? ( +
+
+
+ ) : logs.length === 0 ? ( +
+ No ingestion events recorded yet. Run POST /api/ingest?source=usgs to trigger the first data ingestion. +
+ ) : ( + + + + {["Date", "Source", "Status", "Records", "Errors"].map((h) => ( + + ))} + + + + {logs.map((log) => ( + + + + + + + + ))} + +
{h}
+ {log.completed_at ? new Date(log.completed_at).toLocaleString() : new Date(log.started_at).toLocaleString()} + + + {log.source.toUpperCase()} + + + + {log.status === "success" ? : } + {log.status} + + + {log.records_count.toLocaleString()} + + {log.error_message || "—"} +
+ )} +
+
+ + {/* Citation Guide */} +
+

How to Cite This Data

+

+ Recommended citations for academic publications and reports +

+
+
+

Dataset Citation (APA 7th)

+
+ UDC Water Resources Research Institute. (2026). Anacostia Watershed Water Quality Monitoring Data [Dataset]. University of the District of Columbia, College of Agriculture, Urban Sustainability and Environmental Sciences (CAUSES). Retrieved from https://udc-water.vercel.app/api/export +
+
+
+

API Endpoint for Programmatic Access

+
+
GET /api/stations — List all monitoring stations with latest readings
+
GET /api/stations/:id/history — Historical readings for a station
+
GET /api/export?format=csv&station=ANA-001 — Export data as CSV or JSON
+
GET /api/ingestion-log — View data ingestion history
+
+
+

+ CSV and JSON exports include machine-readable citation metadata. All exports include the source field for each reading. +

+
+
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f77f9f6..713bbb2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -14,16 +14,18 @@ import { StormwaterChart, MultiParameterChart, } from "@/components/charts/WaterQualityCharts"; +import Footer from "@/components/layout/Footer"; import TimeSlider, { type MonthlySnapshot } from "@/components/map/TimeSlider"; import { Droplets, MapPin, TrendingUp, Shield } from "lucide-react"; import { useState, useCallback } from "react"; import type { MonitoringStation } from "@/data/dc-waterways"; import { useTheme } from "@/context/ThemeContext"; +import { useLanguage } from "@/context/LanguageContext"; const DCMap = dynamic(() => import("@/components/map/DCMap"), { ssr: false, loading: () => ( -
+
Loading DC Map... @@ -36,6 +38,7 @@ export default function Dashboard() { const [selectedStation, setSelectedStation] = useState(null); const [monthSnapshot, setMonthSnapshot] = useState(null); const { resolvedTheme } = useTheme(); + const { t } = useLanguage(); const isDark = resolvedTheme === "dark"; const router = useRouter(); @@ -50,11 +53,11 @@ export default function Dashboard() { return (
-
+
-
+
{/* Hero Section */} -
- CAUSES / WRRI + {t("hero.badge")}
-

- DC Water Resources{" "} - Data Dashboard +

+ {t("hero.title")}{" "} + {t("hero.title_highlight")}

-

- Real-time monitoring, analysis, and visualization of water quality data across the - Anacostia River watershed. Integrating research from UDC's Water Resources - Research Institute with environmental data for DC communities. +

+ {t("hero.description")}

{[ - { icon: Droplets, label: "12 Active Sensors", color: "text-blue-400" }, - { icon: MapPin, label: "Anacostia Watershed", color: "text-green-400" }, - { icon: TrendingUp, label: "Real-Time Data", color: "text-cyan-400" }, - { icon: Shield, label: "EPA Standards Tracking", color: "text-amber-400" }, + { icon: Droplets, labelKey: "hero.stations" as const, color: "text-blue-400" }, + { icon: MapPin, labelKey: "hero.watershed" as const, color: "text-green-400" }, + { icon: TrendingUp, labelKey: "hero.usgs" as const, color: "text-cyan-400" }, + { icon: Shield, labelKey: "hero.epa" as const, color: "text-amber-400" }, ].map((item) => (
- {item.label} + {t(item.labelKey)}
))}
+ {/* Data Source Notice */} +
+ +
+ {t("notice.title")}{" "} + {t("notice.text")} +
+
+ {/* Metric Cards */}
@@ -108,19 +122,19 @@ export default function Dashboard() { {/* Interactive Map */}
-
+
-

Interactive Watershed Map

-

- Anacostia River, tributaries, monitoring stations — toggle layers with the control panel +

{t("section.map_title")}

+

+ {t("section.map_desc")}

- - Live monitoring data + + {t("section.map_network")}
-
+
-

Water Quality Analysis

-

- Historical trends and current conditions across monitoring parameters -

+
+

{t("section.wq_title")}

+

+ {t("section.wq_desc")} +

+
@@ -150,50 +166,39 @@ export default function Dashboard() { {/* Multi-parameter overview */}
+
+

{t("section.multi_title")}

+

+ {t("section.multi_desc")} +

+
{/* Environmental Justice */}
+
+

{t("section.ej_title")}

+

+ {t("section.ej_desc")} +

+
{/* Station Table */}
+
+

{t("section.stations_title")}

+

+ {t("section.stations_desc")} +

+
{/* Footer */} -
-
-
-
- UDC -
-
-

- University of the District of Columbia -

-

- College of Agriculture, Urban Sustainability & Environmental Sciences (CAUSES) -

-
-
-
-

- Water Resources Research Institute (WRRI) | Center for Urban Resilience, - Innovation and Infrastructure (CURII) -

-

- Funded by DC Government | Data sources: DOEE, EPA, USGS, Anacostia Riverkeeper -

-
-
-

4200 Connecticut Ave NW

-

Washington, DC 20008

-
-
-
+
diff --git a/src/app/research/page.tsx b/src/app/research/page.tsx index 5da4c29..dc718c8 100644 --- a/src/app/research/page.tsx +++ b/src/app/research/page.tsx @@ -13,9 +13,14 @@ import { DollarSign, Search, Filter, + BookOpen, + Download, + Globe, + ArrowRight, } from "lucide-react"; import { useState } from "react"; import { useTheme } from "@/context/ThemeContext"; +import Link from "next/link"; const tagColors: Record = { "green-infrastructure": "bg-green-500/10 text-green-400 border-green-500/20", @@ -55,11 +60,11 @@ export default function ResearchPage() { return (
-
+
-
+
{/* Page Header */} -
WRRI & CAUSES Research Projects -

+

Active research initiatives from UDC's Water Resources Research Institute and the College of Agriculture, Urban Sustainability & Environmental Sciences. Addressing critical water quality, stormwater management, and environmental justice @@ -86,7 +91,7 @@ export default function ResearchPage() { {/* Filters */}

- +
- +
+ {/* Results count */} +
+ Showing {filteredProjects.length} of {researchProjects.length} projects + {selectedTag && tagged {selectedTag.replace(/-/g, " ")}} + {searchTerm && matching "{searchTerm}"} +
+ {/* Research Projects Grid */}
{filteredProjects.map((project) => (
- + {project.status}
- + + +

{project.title}

-

+

{project.description}

-
- +
+ {project.pi}
-
- +
+ {project.department}
-
- +
+ {project.startDate} — {project.endDate}
-
- +
+ {project.funding}
@@ -198,10 +220,57 @@ export default function ResearchPage() { ))}
+ {/* Quick Links for Researchers */} +
+ + +

Methodology & Data Dictionary

+

+ Sampling protocols, QA/QC procedures, parameter definitions, and EPA threshold documentation. +

+
+ View documentation +
+ + + +

Export Full Dataset

+

+ Download all water quality readings as CSV with citation metadata, source provenance, and station metadata. +

+
+ Download CSV +
+
+ + +

API & Open Data

+

+ REST API access, Python/R analysis templates, and programmatic data retrieval for research integration. +

+
+ Access API docs +
+ +
+ {/* Data Sources */} -
-

Data Integration Sources

-
+
+

Data Integration Sources

+

+ Partner organizations and external data feeds integrated into the dashboard +

+
{[ { name: "DC Dept. of Energy & Environment (DOEE)", @@ -247,7 +316,7 @@ export default function ResearchPage() { }`} >

{source.name}

-

{source.description}

+

{source.description}

{source.datasets.map((ds) => ( = { + usgs: { label: "USGS NWIS", abbr: "USGS", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/30" }, + epa: { label: "EPA Water Quality Exchange", abbr: "EPA", color: "text-green-400", bg: "bg-green-500/10 border-green-500/30" }, + seed: { label: "Baseline/Modeled", abbr: "Model", color: "text-slate-400", bg: "bg-slate-500/10 border-slate-500/30" }, + manual: { label: "Manual Entry", abbr: "Manual", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/30" }, +}; + +function SourceBadge({ source, isDark }: { source: string; isDark: boolean }) { + const cfg = SOURCE_CONFIG[source] || SOURCE_CONFIG.manual; + return ( + + {cfg.abbr} + + ); +} + export default function StationDetailPage() { const params = useParams(); const router = useRouter(); @@ -36,19 +72,135 @@ export default function StationDetailPage() { const isDark = resolvedTheme === "dark"; const stationId = params.id as string; - const station = monitoringStations.find((s) => s.id === stationId); - const historical = getStationHistoricalData(stationId); + const [station, setStation] = useState(null); + const [historical, setHistorical] = useState(null); + const [dataSources, setDataSources] = useState([]); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + const fetchData = useCallback(async () => { + // Find station from static data (always available for metadata, position, etc.) + const staticStation = monitoringStations.find((s) => s.id === stationId); + if (!staticStation) { + setNotFound(true); + setLoading(false); + return; + } + + // Try API first for latest station data + try { + const stationsRes = await fetch("/api/stations"); + if (stationsRes.ok) { + const allStations: MonitoringStation[] = await stationsRes.json(); + const apiStation = allStations.find((s) => s.id === stationId); + if (apiStation) setStation(apiStation); + else setStation(staticStation); + } else { + setStation(staticStation); + } + } catch { + setStation(staticStation); + } - if (!station) { + // Try API for history, fall back to static + try { + const histRes = await fetch(`/api/stations/${stationId}/history`); + if (histRes.ok) { + const histData = await histRes.json(); + if (histData.data && histData.data.length > 0) { + // Collect unique data sources for provenance display + const sources = [...new Set(histData.data.map((r: { source?: string }) => r.source).filter(Boolean))] as string[]; + setDataSources(sources); + + // Transform API data to chart format (group by month, averaging multiple readings per month) + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const monthAccum: Record = {}; + + for (const r of histData.data) { + const date = new Date(r.timestamp); + const monthName = months[date.getMonth()]; + if (!monthAccum[monthName]) { + monthAccum[monthName] = { count: 0, do: 0, temp: 0, ph: 0, turb: 0, ecoli: 0 }; + } + const acc = monthAccum[monthName]; + acc.count++; + if (r.dissolvedOxygen != null) acc.do += r.dissolvedOxygen; + if (r.temperature != null) acc.temp += r.temperature; + if (r.pH != null) acc.ph += r.pH; + if (r.turbidity != null) acc.turb += r.turbidity; + if (r.eColiCount != null) acc.ecoli += r.eColiCount; + } + + const chartData = months + .filter((m) => monthAccum[m]) + .map((m) => { + const a = monthAccum[m]; + const n = a.count || 1; + return { + month: m, + dissolvedOxygen: Math.round((a.do / n) * 100) / 100, + temperature: Math.round((a.temp / n) * 100) / 100, + pH: Math.round((a.ph / n) * 100) / 100, + turbidity: Math.round((a.turb / n) * 100) / 100, + eColiCount: Math.round(a.ecoli / n), + }; + }); + + if (chartData.length > 0) { + const staticHist = getStationHistoricalData(stationId); + setHistorical({ description: staticHist?.description || "", data: chartData }); + } else { + setHistorical(getStationHistoricalData(stationId)); + setDataSources(["seed"]); + } + } else { + setHistorical(getStationHistoricalData(stationId)); + setDataSources(["seed"]); + } + } else { + setHistorical(getStationHistoricalData(stationId)); + setDataSources(["seed"]); + } + } catch { + setHistorical(getStationHistoricalData(stationId)); + setDataSources(["seed"]); + } + + setLoading(false); + }, [stationId]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleExportCSV = () => { + window.open(`/api/export?format=csv&station=${stationId}`, "_blank"); + }; + + if (loading) { return (
-
+
+
+
+
+
+
+
+ ); + } + + if (notFound || !station) { + return ( +
+ +

Station Not Found

-

No station with ID "{stationId}" exists.

+

No station with ID "{stationId}" exists.

@@ -72,7 +224,6 @@ export default function StationDetailPage() { const typeLabel = station.type.replace("-", " ").replace(/\b\w/g, (l) => l.toUpperCase()); const isGI = station.type === "green-infrastructure"; - // EPA thresholds const epaLimits = { dissolvedOxygen: { min: 5, label: "EPA Minimum (5 mg/L)" }, eColiCount: { max: 410, label: "EPA Recreational Limit (410 CFU/100mL)" }, @@ -81,76 +232,81 @@ export default function StationDetailPage() { return (
-
+
-
+
{/* Back + Title */} -
- -
-
-

{station.name}

- -
-
- - - {station.position[0].toFixed(4)}°N, {Math.abs(station.position[1]).toFixed(4)}°W - - ID: {station.id} - Type: {typeLabel} -
- {historical && ( -

{historical.description}

- )} +
+
+ +

{station.name}

+
+
+ + + {station.position[0].toFixed(4)}°N, {Math.abs(station.position[1]).toFixed(4)}°W + + ID: {station.id} + Type: {typeLabel} +
+ {historical && ( +

{historical.description}

+ )}
- -
{/* Current Readings */} {reading && ( -
+
{[ - { label: "Temperature", value: `${reading.temperature}°C`, icon: Thermometer, color: "text-cyan-400", bgDark: "bg-cyan-500/10 border-cyan-500/20", bgLight: "bg-cyan-50 border-cyan-200" }, - { label: "Dissolved Oxygen", value: `${reading.dissolvedOxygen} mg/L`, icon: Droplets, color: "text-blue-400", bgDark: "bg-blue-500/10 border-blue-500/20", bgLight: "bg-blue-50 border-blue-200", - alert: !isGI && reading.dissolvedOxygen < 5 }, - { label: "pH Level", value: `${reading.pH}`, icon: Activity, color: "text-emerald-400", bgDark: "bg-emerald-500/10 border-emerald-500/20", bgLight: "bg-emerald-50 border-emerald-200" }, - { label: "Turbidity", value: `${reading.turbidity} NTU`, icon: Waves, color: "text-amber-400", bgDark: "bg-amber-500/10 border-amber-500/20", bgLight: "bg-amber-50 border-amber-200" }, - { label: "E. coli", value: `${reading.eColiCount.toLocaleString()}`, unit: "CFU/100mL", icon: AlertTriangle, - color: reading.eColiCount > 410 ? "text-red-400" : "text-green-400", - bgDark: reading.eColiCount > 410 ? "bg-red-500/10 border-red-500/20" : "bg-green-500/10 border-green-500/20", - bgLight: reading.eColiCount > 410 ? "bg-red-50 border-red-200" : "bg-green-50 border-green-200", - alert: reading.eColiCount > 410 }, - { label: "Conductivity", value: `${reading.conductivity}`, unit: "µS/cm", icon: Activity, color: "text-purple-400", bgDark: "bg-purple-500/10 border-purple-500/20", bgLight: "bg-purple-50 border-purple-200" }, + { label: "Temperature", value: reading.temperature != null ? `${reading.temperature}°C` : "—", icon: Thermometer, color: "text-cyan-400", bgDark: "bg-cyan-500/10 border-cyan-500/20", bgLight: "bg-cyan-50 border-cyan-200" }, + { label: "Dissolved Oxygen", value: reading.dissolvedOxygen != null ? `${reading.dissolvedOxygen} mg/L` : "—", icon: Droplets, color: "text-blue-400", bgDark: "bg-blue-500/10 border-blue-500/20", bgLight: "bg-blue-50 border-blue-200", + alert: !isGI && (reading.dissolvedOxygen ?? Infinity) < 5 }, + { label: "pH Level", value: reading.pH != null ? `${reading.pH}` : "—", icon: Activity, color: "text-emerald-400", bgDark: "bg-emerald-500/10 border-emerald-500/20", bgLight: "bg-emerald-50 border-emerald-200" }, + { label: "Turbidity", value: reading.turbidity != null ? `${reading.turbidity} NTU` : "—", icon: Waves, color: "text-amber-400", bgDark: "bg-amber-500/10 border-amber-500/20", bgLight: "bg-amber-50 border-amber-200" }, + { label: "E. coli", value: reading.eColiCount != null ? `${reading.eColiCount.toLocaleString()}` : "—", unit: "CFU/100mL", icon: AlertTriangle, + color: (reading.eColiCount ?? 0) > 410 ? "text-red-400" : "text-green-400", + bgDark: (reading.eColiCount ?? 0) > 410 ? "bg-red-500/10 border-red-500/20" : "bg-green-500/10 border-green-500/20", + bgLight: (reading.eColiCount ?? 0) > 410 ? "bg-red-50 border-red-200" : "bg-green-50 border-green-200", + alert: (reading.eColiCount ?? 0) > 410 }, + { label: "Conductivity", value: reading.conductivity != null ? `${reading.conductivity}` : "—", unit: "µS/cm", icon: Activity, color: "text-purple-400", bgDark: "bg-purple-500/10 border-purple-500/20", bgLight: "bg-purple-50 border-purple-200" }, ].map((metric) => { const Icon = metric.icon; return ( -
+
{metric.alert && }
-
{metric.value}
- {metric.unit &&
{metric.unit}
} -
{metric.label}
+
{metric.value}
+ {metric.unit &&
{metric.unit}
} +
{metric.label}
); })} @@ -158,8 +314,28 @@ export default function StationDetailPage() { )} {reading && ( -
- Last updated: {new Date(reading.timestamp).toLocaleString()} +
+ Last updated: {reading.timestamp + ? dataSources.length === 1 && dataSources[0] === "seed" + ? "Baseline (modeled)" + : new Date(reading.timestamp).toLocaleString() + : "—"} + {dataSources.length > 0 && ( + + Sources: + {dataSources.map((src) => ( + + ))} + + )} + {dataSources.length === 1 && dataSources[0] === "seed" && ( + + + Baseline data — no live sensor feed for this station + + )}
)} @@ -167,17 +343,20 @@ export default function StationDetailPage() { {historical && ( <>
-

Historical Trends (2025)

-

Monthly averages with EPA compliance thresholds

+

+ Historical Trends + {dataSources.length === 1 && dataSources[0] === "seed" ? " (Baseline)" : ""} +

+

Monthly averages with EPA compliance thresholds

{/* Dissolved Oxygen */} {!isGI && ( -
+

Dissolved Oxygen

-

Monthly average (mg/L) with EPA minimum threshold

- +

Monthly average (mg/L) with EPA minimum threshold

+ @@ -197,10 +376,10 @@ export default function StationDetailPage() { )} {/* Temperature */} -
+

Water Temperature

-

Monthly average (°C)

- +

Monthly average (°C)

+ @@ -218,10 +397,10 @@ export default function StationDetailPage() {
{/* E. coli */} -
+

E. coli Levels

-

Monthly average (CFU/100mL) with EPA recreational limit

- +

Monthly average (CFU/100mL) with EPA recreational limit

+ @@ -234,10 +413,10 @@ export default function StationDetailPage() {
{/* Turbidity */} -
+

Turbidity

-

Monthly average (NTU)

- +

Monthly average (NTU)

+ @@ -257,10 +436,10 @@ export default function StationDetailPage() { {/* Multi-parameter overlay */} {!isGI && ( -
+

Multi-Parameter Comparison

-

All parameters overlaid for correlation analysis

- +

All parameters overlaid for correlation analysis

+ @@ -278,7 +457,7 @@ export default function StationDetailPage() { )} {/* Parameters monitored */} -
+

Parameters Monitored

{station.parameters.map((param) => ( diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..3f4ca4e --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export default class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+ ! +
+

+ Something went wrong +

+

+ {this.state.error?.message || "An unexpected error occurred."} +

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..0e433a9 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useEffect, useCallback, useRef } from "react"; +import { X } from "lucide-react"; +import { useTheme } from "@/context/ThemeContext"; + +interface ModalProps { + open: boolean; + onClose: () => void; + title: string; + subtitle?: string; + icon?: React.ReactNode; + children: React.ReactNode; + maxWidth?: string; +} + +export default function Modal({ open, onClose, title, subtitle, icon, children, maxWidth = "max-w-2xl" }: ModalProps) { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const overlayRef = useRef(null); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose] + ); + + useEffect(() => { + if (open) { + document.addEventListener("keydown", handleKeyDown); + document.body.style.overflow = "hidden"; + } + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = ""; + }; + }, [open, handleKeyDown]); + + if (!open) return null; + + return ( +
{ + if (e.target === overlayRef.current) onClose(); + }} + role="dialog" + aria-modal="true" + aria-label={title} + > + {/* Backdrop */} +
+ + {/* Modal Panel */} +
+ {/* Header */} +
+
+ {icon && ( +
+ {icon} +
+ )} +
+

{title}

+ {subtitle && ( +

{subtitle}

+ )} +
+
+ +
+ + {/* Body */} +
{children}
+
+
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx new file mode 100644 index 0000000..46446d5 --- /dev/null +++ b/src/components/SettingsModal.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Sun, Moon, Monitor, Settings } from "lucide-react"; +import { useTheme, type Theme } from "@/context/ThemeContext"; +import { useLanguage } from "@/context/LanguageContext"; +import type { Locale } from "@/lib/i18n"; +import Modal from "./Modal"; + +const themeOptions: { value: Theme; labelKey: "settings.theme_light" | "settings.theme_dark" | "settings.theme_system"; icon: typeof Sun }[] = [ + { value: "light", labelKey: "settings.theme_light", icon: Sun }, + { value: "dark", labelKey: "settings.theme_dark", icon: Moon }, + { value: "system", labelKey: "settings.theme_system", icon: Monitor }, +]; + +const languageOptions: { value: Locale; label: string; flag: string }[] = [ + { value: "en", label: "English", flag: "🇺🇸" }, + { value: "es", label: "Español", flag: "🇪🇸" }, +]; + +interface SettingsModalProps { + open: boolean; + onClose: () => void; +} + +export default function SettingsModal({ open, onClose }: SettingsModalProps) { + const { theme, setTheme, resolvedTheme } = useTheme(); + const { locale, setLocale, t } = useLanguage(); + const isDark = resolvedTheme === "dark"; + + return ( + } + maxWidth="max-w-md" + > +
+ {/* Appearance */} +
+ +
+ {themeOptions.map((opt) => { + const Icon = opt.icon; + const isActive = theme === opt.value; + return ( + + ); + })} +
+
+ + {/* Language */} +
+ +
+ {languageOptions.map((opt) => { + const isActive = locale === opt.value; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/src/components/ai/ResearchAssistant.tsx b/src/components/ai/ResearchAssistant.tsx new file mode 100644 index 0000000..e855f8e --- /dev/null +++ b/src/components/ai/ResearchAssistant.tsx @@ -0,0 +1,434 @@ +"use client"; + +import { useChat } from "@ai-sdk/react"; +import { DefaultChatTransport } from "ai"; +import { useTheme } from "@/context/ThemeContext"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { + X, + Send, + Bot, + User, + Loader2, + AlertTriangle, + Sparkles, + ChevronDown, + Trash2, +} from "lucide-react"; + +const SUGGESTED_QUESTIONS = [ + "What's the current water quality at Anacostia Park?", + "Which stations have the highest E. coli levels?", + "Explain the seasonal DO trends in the Anacostia River", + "How do CSOs affect Wards 7 and 8 water quality?", + "What green infrastructure projects is UDC researching?", + "Compare water quality across all monitoring stations", +]; + +export default function ResearchAssistant() { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === "dark"; + const [isOpen, setIsOpen] = useState(false); + const [hasApiKey, setHasApiKey] = useState(null); + const [input, setInput] = useState(""); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const { + messages, + sendMessage, + status, + error, + setMessages, + } = useChat({ + transport: new DefaultChatTransport({ api: "/api/chat" }), + }); + + const isLoading = status === "streaming" || status === "submitted"; + + // Check if API key is configured + useEffect(() => { + if (isOpen && hasApiKey === null) { + fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ messages: [] }), + }) + .then((r) => setHasApiKey(r.status !== 503)) + .catch(() => setHasApiKey(false)); + } + }, [isOpen, hasApiKey]); + + // Scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Focus input when panel opens + useEffect(() => { + if (isOpen) { + setTimeout(() => inputRef.current?.focus(), 200); + } + }, [isOpen]); + + const handleSend = useCallback( + async (text?: string) => { + const msg = (text || input).trim(); + if (!msg || isLoading) return; + setInput(""); + await sendMessage({ text: msg }); + }, + [input, isLoading, sendMessage], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + return ( + <> + {/* Floating Action Button */} + + + {/* Chat Panel */} +
+
+ {/* Header */} +
+
+
+ +
+
+

+ Research Assistant +

+

+ Powered by Claude AI +

+
+
+
+ {messages.length > 0 && ( + + )} + +
+
+ + {/* Messages Area */} +
+ {/* API key not configured */} + {hasApiKey === false && ( +
+ +
+

AI Assistant Not Configured

+

+ Set ANTHROPIC_API_KEY in your + environment variables to enable the research assistant. +

+
+
+ )} + + {/* Welcome message */} + {messages.length === 0 && hasApiKey !== false && ( +
+
+
+ +
+
+

+ Hi! I'm the UDC Water Resources Research Assistant. I can help you: +

+
    +
  • Analyze water quality data across 12 monitoring stations
  • +
  • Explain trends, flag anomalies, and interpret EPA thresholds
  • +
  • Answer questions about WRRI research and green infrastructure
  • +
  • Guide you through environmental justice data for DC wards
  • +
+

Try one of the questions below, or ask your own:

+
+
+ + {/* Suggested questions */} +
+ {SUGGESTED_QUESTIONS.map((q) => ( + + ))} +
+
+ )} + + {/* Message thread */} + {messages.map((message) => ( +
+ {/* Avatar */} +
+ {message.role === "user" ? ( + + ) : ( + + )} +
+ + {/* Bubble */} +
+ {message.parts?.map((part, i) => { + if (part.type === "text") { + return ( +
+ ); + } + if (part.type?.startsWith("tool-")) { + const toolPart = part as { type: string; state?: string; toolCallId?: string }; + const isResult = toolPart.state === "result"; + return ( +
+ + {isResult ? "Retrieved station data" : "Querying station data..."} +
+ ); + } + return null; + })} +
+
+ ))} + + {/* Streaming indicator */} + {isLoading && messages[messages.length - 1]?.role === "user" && ( +
+
+ +
+
+
+ + + +
+
+
+ )} + + {/* Error display */} + {error && ( +
+ +
+

Something went wrong

+

{error.message || "Please try again."}

+
+
+ )} + +
+
+ + {/* Input Area */} +
+
+