Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
**/node_modules
**/dist
**/.venv
**/__pycache__
**/*.pyc
.git
.github
.design_tmp
**/.env
frontend/.vite
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
backend:
name: Backend (lint + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Sync dependencies
run: uv sync --frozen

- name: Lint (ruff)
run: |
uv run ruff check .
uv run ruff format --check .

- name: Test (pytest)
run: uv run pytest

frontend:
name: Frontend (lint + test + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Lint (eslint)
run: npm run lint

- name: Test (vitest)
run: npm test

- name: Build
run: npm run build

container-scan:
name: Scan ${{ matrix.name }} image (grype)
runs-on: ubuntu-latest
needs: [backend, frontend]
strategy:
fail-fast: false
matrix:
include:
- name: prod
context: .
dockerfile: Dockerfile
- name: backend-dev
context: ./backend
dockerfile: backend/Dockerfile.dev
- name: frontend-dev
context: ./frontend
dockerfile: frontend/Dockerfile.dev
steps:
- uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
tags: bto-${{ matrix.name }}:ci
load: true
cache-from: type=gha
cache-to: type=gha,mode=max

- name: Scan image with Grype
uses: anchore/scan-action@v6
with:
image: bto-${{ matrix.name }}:ci
fail-build: true
severity-cutoff: high
only-fixed: true
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,18 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# Node / frontend
node_modules/
frontend/dist/
frontend/.vite/
*.local

# Design handoff bundle (local only)
.design_tmp/

# Env / secrets
.env

# Local Claude config (machine-specific)
.claude/
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1

# --- Stage 1: build the frontend ---
FROM node:22-slim AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build

# --- Stage 2: backend runtime ---
FROM python:3.13-slim AS backend
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

# Install dependencies first for better layer caching.
COPY backend/pyproject.toml backend/uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY backend/ ./

# Built frontend served as static files by FastAPI.
COPY --from=frontend /frontend/dist ./static
ENV BTO_STATIC_DIR=/app/static

EXPOSE 8000
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,79 @@
# Bellingham Tidal Observatory (BTO)

This is application gathers data from the [NOAA CO-OPS API](https://api.tidesandcurrents.noaa.gov/api/prod/) for visualizing the waters of the Bellingham Bay across different stations.
A Grafana/Ganglia-style dashboard for the tides of the inner Salish Sea. It pulls
real hi/lo predictions from the [NOAA CO-OPS API](https://api.tidesandcurrents.noaa.gov/api/prod/)
for the Bellingham Bay / San Juan stations, interpolates a smooth tide curve, and
renders a dense, customizable observatory view (station grid, focus drilldown,
compare overlay, time ranges, density, grouping).

- **Frontend** — React + TypeScript + Vite (`frontend/`)
- **Backend** — FastAPI on a `uv` environment, wrapping `noaa_coops` (`backend/`)

## Development

### With Docker Compose (recommended)

Runs both services with hot-reload:

```bash
docker compose up --build
```

- UI: http://localhost:5173
- API: http://localhost:8000

### Without Docker

Backend:

```bash
cd backend
uv run uvicorn app.main:app --reload
```

Frontend (separate terminal):

```bash
cd frontend
npm install
npm run dev
```

The Vite dev server proxies `/api` to the backend on port 8000.

## Production build

The root [Dockerfile](Dockerfile) builds the frontend and serves it as static
files from FastAPI in a single container:

```bash
docker build -t bto .
docker run -p 8000:8000 bto
```

The app is then available at http://localhost:8000.

## Configuration

Backend settings come from `backend/.env` (all prefixed `BTO_`). Copy the example:

```bash
cp backend/.env.example backend/.env
```

| Variable | Description |
| ------------------ | ------------------------------------------------------ |
| `BTO_STATIC_DIR` | Path to the built frontend; serves the UI when set. |
| `BTO_CORS_ORIGINS` | Allowed CORS origins (JSON list). |
| `BTO_CACHE_TTL` | Seconds to cache NOAA prediction responses in-memory. |

## Data notes

The tide curve is built by half-cosine interpolation between **real NOAA hi/lo
predictions** (reliable across both harmonic and subordinate stations). Sun/moon
facts are computed with `astral`. NOAA's prediction feed has no solunar or
per-station weather data, so the focus panel's secondary slots show real
sun/moon and station metadata rather than the prototype's invented values.

## Stations List

Expand Down
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy to .env and fill in. All vars are prefixed BTO_.

# Path to the built frontend (dist/). Leave unset in dev — Vite serves the UI.
# BTO_STATIC_DIR=/app/static

# Allowed CORS origins in dev (JSON list).
# BTO_CORS_ORIGINS=["http://localhost:5173"]

# Seconds to cache NOAA prediction responses in-memory.
# BTO_CACHE_TTL=900
File renamed without changes.
12 changes: 12 additions & 0 deletions backend/Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# syntax=docker/dockerfile:1
# Development image: deps baked in, source mounted at runtime for --reload.
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project

EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
Empty file added backend/app/__init__.py
Empty file.
115 changes: 115 additions & 0 deletions backend/app/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Bellingham Tidal Observatory REST API.

A single ``/api/bootstrap`` call returns everything the dashboard needs: station
metadata plus real NOAA hi/lo predictions (extrema) spanning a wide window, plus
sun/moon facts. The frontend interpolates the smooth tide curve from those
extrema client-side, so range/density/hover changes need no further round trips.
"""

from __future__ import annotations

import asyncio
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo

from fastapi import APIRouter, HTTPException

from app import astro, noaa, stations

router = APIRouter(prefix="/api")

_TZ = ZoneInfo("America/Los_Angeles")

# How far around "now" to fetch hi/lo predictions — wide enough to cover the
# dashboard's longest range (30D) panned either direction.
_PAST_DAYS = 20
_FUTURE_DAYS = 20


def _epoch_ms(dt: datetime) -> int:
# NOAA lst_ldt times are Pacific wall-clock but tz-naive; localize them.
if dt.tzinfo is None:
dt = dt.replace(tzinfo=_TZ)
return int(dt.timestamp() * 1000)


def _serialize_extrema(extrema: list[noaa.Extremum]) -> list[dict[str, Any]]:
return [
{"t": _epoch_ms(e.t), "height": round(e.height, 3), "kind": e.kind}
for e in extrema
]


async def _station_payload(s: stations.Station, begin: str, end: str) -> dict[str, Any]:
extrema = await noaa.fetch_extrema(s.id, begin, end)
return {
"id": s.id,
"code": s.code,
"name": s.name,
"area": s.area,
"lat": s.lat,
"lon": s.lon,
"predictions": s.predictions,
"group": s.group,
"extrema": _serialize_extrema(extrema),
}


async def _bootstrap() -> dict[str, Any]:
now = datetime.now(_TZ)
begin = (now - timedelta(days=_PAST_DAYS)).strftime("%Y%m%d")
end = (now + timedelta(days=_FUTURE_DAYS)).strftime("%Y%m%d")

payloads = await asyncio.gather(
*(_station_payload(s, begin, end) for s in stations.STATIONS)
)

sm = astro.sun_moon(now.date())
return {
"now": _epoch_ms(now),
"tz": "America/Los_Angeles",
"datum": "MLLW",
"units": "english",
"date": {
"iso": now.strftime("%Y-%m-%d"),
"pretty": now.strftime("%a · %b %-d, %Y"),
},
"sun_moon": {
"sunrise": sm.sunrise,
"sunset": sm.sunset,
"noon": sm.noon,
"moon_phase": sm.moon_phase,
"moon_illum": sm.moon_illum,
"moon_glyph": sm.moon_glyph,
},
"stations": list(payloads),
}


@router.get("/bootstrap")
async def bootstrap() -> dict[str, Any]:
"""Everything the frontend loads on startup, in one round trip."""
try:
return await _bootstrap()
except Exception as exc: # noqa: BLE001 — surface upstream NOAA failures cleanly
raise HTTPException(
status_code=502, detail=f"NOAA upstream error: {exc}"
) from exc


@router.get("/stations")
def list_stations() -> list[dict[str, Any]]:
return [
{
"id": s.id,
"code": s.code,
"name": s.name,
"area": s.area,
"lat": s.lat,
"lon": s.lon,
"predictions": s.predictions,
"group": s.group,
}
for s in stations.STATIONS
]
Loading
Loading