diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 76% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index b8296512..348fc716 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Test +name: CI on: push: @@ -40,6 +40,7 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -50,6 +51,12 @@ jobs: - name: Install modules run: npm install + - name: Install Playwright browsers + run: npx playwright install chromium + + # Do not run npm build here! This intentonally runs tests against the unbuilt source + # to ensure that the tests are properly configured to run against the source files. + - name: Test run: npm run coverage @@ -60,6 +67,26 @@ jobs: fail_ci_if_error: true files: ./coverage/coverage-final.json + publish: + runs-on: ubuntu-latest + permissions: + id-token: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Install modules + run: npm install + + - name: Build + run: npm run build + + - name: Publish to pkg.pr.new + run: npx pkg-pr-new publish ./packages/* + benchmarks: runs-on: ubuntu-latest concurrency: diff --git a/aliases.ts b/aliases.ts new file mode 100644 index 00000000..b7aba047 --- /dev/null +++ b/aliases.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = import.meta.dirname; + +interface PackageJson { + name: string; + workspaces?: string[]; +} + +const rootPkg: PackageJson = JSON.parse(readFileSync(resolve(root, "package.json"), "utf-8")); + +/** + * Resolve aliases for all workspace packages to their `src/index.ts` entry + * points. Optionally exclude the current package (to avoid self-aliasing). + */ +export function aliases(exclude?: string): Record { + const result: Record = {}; + + for (const workspace of rootPkg.workspaces ?? []) { + const pkgPath = resolve(root, workspace, "package.json"); + const pkg: PackageJson = JSON.parse(readFileSync(pkgPath, "utf-8")); + + if (pkg.name === exclude) continue; + + result[pkg.name] = resolve(root, workspace, "src/index.ts"); + } + + return result; +} diff --git a/package.json b/package.json index 48ac1597..95d8cc97 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "globals": "^17.2.0", "make-fetch-happen": "^15.0.3", "npm-run-all": "^4.1.5", + "pkg-pr-new": "^0.0.65", "prettier": "^3.7.4", "tsdown": "^0.21.0", "typescript": "^5.3.3", @@ -34,6 +35,7 @@ "packages/tide-predictor", "packages/neaps", "packages/api", - "packages/cli" + "packages/cli", + "packages/react" ] } diff --git a/packages/api/vitest.config.ts b/packages/api/vitest.config.ts index a5415464..b0d51079 100644 --- a/packages/api/vitest.config.ts +++ b/packages/api/vitest.config.ts @@ -1,12 +1,10 @@ import { defineProject } from "vitest/config"; import { resolve } from "node:path"; +import { aliases } from "../../aliases.js"; export default defineProject({ resolve: { - alias: { - neaps: resolve(__dirname, "../neaps/src/index.ts"), - "@neaps/tide-predictor": resolve(__dirname, "../tide-predictor/src/index.ts"), - }, + alias: aliases("@neaps/api"), }, test: { environment: "node", diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 115d0a0d..b7e9dfb6 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -1,13 +1,10 @@ import { defineProject } from "vitest/config"; import { resolve } from "node:path"; +import { aliases } from "../../aliases.js"; export default defineProject({ resolve: { - alias: { - neaps: resolve(__dirname, "../neaps/src/index.ts"), - "@neaps/api": resolve(__dirname, "../api/src/index.ts"), - "@neaps/tide-predictor": resolve(__dirname, "../tide-predictor/src/index.ts"), - }, + alias: aliases("@neaps/cli"), }, test: { environment: "node", diff --git a/packages/neaps/vitest.config.ts b/packages/neaps/vitest.config.ts index 3ee0f6fc..221534f0 100644 --- a/packages/neaps/vitest.config.ts +++ b/packages/neaps/vitest.config.ts @@ -1,11 +1,10 @@ import { defineProject } from "vitest/config"; import { resolve } from "node:path"; +import { aliases } from "../../aliases.js"; export default defineProject({ resolve: { - alias: { - "@neaps/tide-predictor": resolve(__dirname, "../tide-predictor/src/index.ts"), - }, + alias: aliases("neaps"), }, test: { environment: "node", diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts new file mode 100644 index 00000000..7245a85c --- /dev/null +++ b/packages/react/.storybook/main.ts @@ -0,0 +1,30 @@ +import type { StorybookConfig } from "@storybook/react-vite"; +import tailwindcss from "@tailwindcss/vite"; +import { createApp } from "@neaps/api"; + +const API_PORT = 6007; + +const config: StorybookConfig = { + stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-themes"], + framework: { + name: "@storybook/react-vite", + options: {}, + }, + viteFinal(config) { + config.plugins ??= []; + config.plugins.push(tailwindcss()); + config.plugins.push({ + name: "neaps-api", + async configureServer() { + const app = createApp(); + app.listen(API_PORT, "0.0.0.0", () => { + console.log(`Neaps API listening on http://0.0.0.0:${API_PORT}`); + }); + }, + }); + return config; + }, +}; + +export default config; diff --git a/packages/react/.storybook/manager.ts b/packages/react/.storybook/manager.ts new file mode 100644 index 00000000..2326dd7f --- /dev/null +++ b/packages/react/.storybook/manager.ts @@ -0,0 +1,6 @@ +import { addons } from "storybook/internal/manager-api"; +import { light, dark } from "./theme.js"; + +const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + +addons.setConfig({ theme: prefersDark ? dark : light }); diff --git a/packages/react/.storybook/preview.tsx b/packages/react/.storybook/preview.tsx new file mode 100644 index 00000000..104ea9fa --- /dev/null +++ b/packages/react/.storybook/preview.tsx @@ -0,0 +1,34 @@ +import type { Preview } from "@storybook/react"; +import { withThemeByDataAttribute } from "@storybook/addon-themes"; +import { NeapsProvider } from "../src/provider.js"; +import "./storybook.css"; + +const API_URL = `${window.location.protocol}//${window.location.hostname}:6007`; + +const preview: Preview = { + decorators: [ + (Story) => ( + + + + ), + withThemeByDataAttribute({ + themes: { + light: "light", + dark: "dark", + }, + defaultTheme: "light", + attributeName: "data-theme", + }), + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +}; + +export default preview; diff --git a/packages/react/.storybook/storybook.css b/packages/react/.storybook/storybook.css new file mode 100644 index 00000000..ea057e00 --- /dev/null +++ b/packages/react/.storybook/storybook.css @@ -0,0 +1,10 @@ +@import "tailwindcss"; +@import "../src/styles.css"; + +[data-theme="dark"] { + color-scheme: dark; +} + +[data-theme="light"] { + color-scheme: light; +} diff --git a/packages/react/.storybook/theme.ts b/packages/react/.storybook/theme.ts new file mode 100644 index 00000000..2b865657 --- /dev/null +++ b/packages/react/.storybook/theme.ts @@ -0,0 +1,9 @@ +import { create } from "storybook/internal/theming"; + +const brand = { + brandTitle: "Neaps", + brandUrl: "https://openwaters.io/tides/neaps", +}; + +export const light = create({ base: "light", ...brand }); +export const dark = create({ base: "dark", ...brand }); diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 00000000..841ec0f5 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,203 @@ +# @neaps/react + +React components for tide predictions powered by [Neaps](https://openwaters.io/tides/neaps). + +## Installation + +```sh +npm install @neaps/react +``` + +Peer dependencies: + +```sh +npm install react react-dom +# Optional — needed for +npm install maplibre-gl react-map-gl +``` + +## Quick Start + +Wrap your app with `` and point it at a running [`@neaps/api`](../api) instance: + +```tsx +import { NeapsProvider, TideStation } from "@neaps/react"; +import "@neaps/react/styles.css"; + +function App() { + return ( + + + + ); +} +``` + +## Components + +## Provider + +`` configures the API base URL, default units, and datum for all child components. + +```tsx + + {children} + +``` + +| Prop | Type | Default | Description | +| ------------- | -------------------- | ------------ | ------------------------------ | +| `baseUrl` | `string` | — | API server URL | +| `units` | `"meters" \| "feet"` | `"meters"` | Display units | +| `datum` | `string` | chart datum | Vertical datum (e.g. `"MLLW"`) | +| `queryClient` | `QueryClient` | auto-created | Custom TanStack Query client | + +### `` + +All-in-one display for a single station — name, graph, and table. + +```tsx + +``` + +| Prop | Type | Default | Description | +| ----------- | ----------------------------- | ------- | ---------------------------------- | +| `id` | `string` | — | Station ID (e.g. `"noaa/8443970"`) | +| `showGraph` | `boolean` | `true` | Show tide graph | +| `showTable` | `boolean` | `true` | Show extremes table | +| `timeRange` | `TimeRange \| { start, end }` | `"24h"` | Time window | + +### `` + +Current water level, rising/falling indicator, and next extreme. Used internally by `` but also available standalone. + +```tsx + +``` + +### `` + +Tide level chart. Pass data directly or fetch by station ID. + +```tsx +// Fetch mode + + +// Data mode + +``` + +### `` + +High/low tide extremes in a table. Pass data directly or fetch by station ID. + +```tsx + +``` + +### `` + +Autocomplete search input for finding stations. + +```tsx + console.log(station)} /> +``` + +### `` + +List of stations near a given station or coordinates. + +```tsx + + +``` + +### `` + +Interactive map showing tide stations within the visible viewport. Requires `maplibre-gl` and `react-map-gl`. Stations are fetched by bounding box as the user pans and zooms. + +```tsx + console.log(station)} +/> +``` + +## Hooks + +All hooks must be used within a ``. + +- `useStation(id)` — fetch a single station +- `useStations({ query?, bbox?, latitude?, longitude? })` — search/list stations (supports bounding box as `[[minLon, minLat], [maxLon, maxLat]]`) +- `useExtremes({ id, start?, end?, days? })` — fetch high/low extremes +- `useTimeline({ id, start?, end? })` — fetch tide level timeline +- `useNearbyStations({ stationId } | { latitude, longitude })` — fetch nearby stations + +## Styling + +Components are styled with [Tailwind CSS v4](https://tailwindcss.com) and CSS custom properties for theming. + +### With Tailwind + +Add `@neaps/react` to your Tailwind content paths so its classes are included in your build: + +```css +/* app.css */ +@import "tailwindcss"; +@source "../node_modules/@neaps/react/dist"; +``` + +Import the theme variables: + +```css +@import "@neaps/react/styles.css"; +``` + +### Without Tailwind + +Import the pre-built stylesheet which includes all resolved Tailwind utilities: + +```tsx +import "@neaps/react/styles.css"; +``` + +### Theme Variables + +Override CSS custom properties to match your brand: + +```css +:root { + --neaps-primary: #2563eb; + --neaps-high: #3b82f6; /* High tide color */ + --neaps-low: #f59e0b; /* Low tide color */ + --neaps-bg: #ffffff; + --neaps-bg-subtle: #f8fafc; + --neaps-text: #0f172a; + --neaps-text-muted: #64748b; + --neaps-border: #e2e8f0; +} +``` + +### Dark Mode + +Dark mode activates automatically based on the user's system preference via the CSS [`color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme) property. You can also force dark or light mode on any container: + +```css +.my-widget { + color-scheme: dark; /* or "light" */ +} +``` + +Override dark mode colors using `light-dark()`: + +```css +:root { + --neaps-primary: light-dark(#2563eb, #60a5fa); + --neaps-bg: light-dark(#ffffff, #0f172a); + --neaps-text: light-dark(#0f172a, #f1f5f9); +} +``` + +## License + +MIT diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..5a67537a --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,90 @@ +{ + "name": "@neaps/react", + "version": "0.1.0", + "description": "React components for tide predictions", + "keywords": [ + "tides", + "react", + "components" + ], + "homepage": "https://openwaters.io/tides/neaps", + "bugs": { + "url": "https://github.com/openwatersio/neaps/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openwatersio/neaps.git", + "directory": "packages/react" + }, + "license": "MIT", + "author": "Brandon Keepers ", + "type": "module", + "main": "dist/index.cjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./styles.css": "./dist/styles.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "prepack": "npm run build", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", + "build-storybook": "storybook build" + }, + "peerDependencies": { + "maplibre-gl": ">=4", + "react": ">=18", + "react-dom": ">=18", + "react-map-gl": ">=7" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + }, + "react-map-gl": { + "optional": true + } + }, + "dependencies": { + "@tanstack/react-query": "^5.64.0", + "@visx/axis": "^4.0.1-alpha.0", + "@visx/curve": "^4.0.1-alpha.0", + "@visx/event": "^4.0.1-alpha.0", + "@visx/gradient": "^4.0.1-alpha.0", + "@visx/group": "^4.0.1-alpha.0", + "@visx/scale": "^4.0.1-alpha.0", + "@visx/shape": "^4.0.1-alpha.0", + "@visx/tooltip": "^4.0.1-alpha.0", + "astronomy-engine": "^2.1.19", + "coordinate-format": "^1.0.0", + "culori": "^4.0.2", + "d3-array": "^3.2.1", + "date-fns": "^3.6.0" + }, + "devDependencies": { + "@storybook/addon-themes": "^10.2.16", + "@storybook/react": "^10.2.10", + "@storybook/react-vite": "^10.2.10", + "@tailwindcss/vite": "^4.2.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.6.1", + "@types/culori": "^4.0.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitest/browser-playwright": "^4.0.18", + "axe-core": "^4.11.1", + "maplibre-gl": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-map-gl": "^7.0.0", + "storybook": "^10.2.10", + "tailwindcss": "^4.2.0" + } +} diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts new file mode 100644 index 00000000..9d2bb399 --- /dev/null +++ b/packages/react/src/client.ts @@ -0,0 +1,126 @@ +import type { + Units, + Station, + StationSummary, + ExtremesResponse, + TimelineResponse, +} from "./types.js"; + +export interface PredictionParams { + start?: string; + end?: string; + datum?: string; + units?: Units; +} + +export interface LocationParams extends PredictionParams { + latitude: number; + longitude: number; +} + +export interface StationPredictionParams extends PredictionParams { + id: string; +} + +export interface StationsSearchParams { + query?: string; + latitude?: number; + longitude?: number; + maxResults?: number; + maxDistance?: number; + bbox?: string; // "minLon,minLat,maxLon,maxLat" +} + +async function fetchJSON(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const body = await res.json().catch(() => ({ message: res.statusText })); + throw new Error(body.message ?? `Request failed: ${res.status}`); + } + return res.json() as Promise; +} + +function buildURL(base: string, path: string, params: object = {}): string { + const normalizedBase = base.endsWith("/") ? base : base + "/"; + const relativePath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(relativePath, normalizedBase); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, String(value)); + } + } + return url.toString(); +} + +/** Parse a station ID like "noaa/8722588" into source and id path segments. */ +function parseStationId(id: string): { source: string; stationId: string } { + const slash = id.indexOf("/"); + if (slash === -1) throw new Error(`Invalid station ID: "${id}". Expected format "source/id".`); + return { source: id.slice(0, slash), stationId: id.slice(slash + 1) }; +} + +/** Convert all Date properties to string (the raw JSON shape before parsing). */ +type JSONResponse = { + [K in keyof T]: T[K] extends Date + ? string + : T[K] extends (infer U)[] + ? JSONResponse[] + : T[K] extends object + ? JSONResponse + : T[K]; +}; + +type RawExtremesResponse = JSONResponse; +type RawTimelineResponse = JSONResponse; + +export async function fetchExtremes( + baseUrl: string, + params: LocationParams, +): Promise { + const data = await fetchJSON(buildURL(baseUrl, "/tides/extremes", params)); + return { ...data, extremes: data.extremes.map((e) => ({ ...e, time: new Date(e.time) })) }; +} + +export async function fetchTimeline( + baseUrl: string, + params: LocationParams, +): Promise { + const data = await fetchJSON(buildURL(baseUrl, "/tides/timeline", params)); + return { ...data, timeline: data.timeline.map((e) => ({ ...e, time: new Date(e.time) })) }; +} + +export function fetchStation(baseUrl: string, id: string): Promise { + const { source, stationId } = parseStationId(id); + return fetchJSON(buildURL(baseUrl, `/tides/stations/${source}/${stationId}`)); +} + +export function fetchStations( + baseUrl: string, + params: StationsSearchParams = {}, +): Promise { + return fetchJSON(buildURL(baseUrl, "/tides/stations", params)); +} + +export async function fetchStationExtremes( + baseUrl: string, + params: StationPredictionParams, +): Promise { + const { id, ...rest } = params; + const { source, stationId } = parseStationId(id); + const data = await fetchJSON( + buildURL(baseUrl, `/tides/stations/${source}/${stationId}/extremes`, rest), + ); + return { ...data, extremes: data.extremes.map((e) => ({ ...e, time: new Date(e.time) })) }; +} + +export async function fetchStationTimeline( + baseUrl: string, + params: StationPredictionParams, +): Promise { + const { id, ...rest } = params; + const { source, stationId } = parseStationId(id); + const data = await fetchJSON( + buildURL(baseUrl, `/tides/stations/${source}/${stationId}/timeline`, rest), + ); + return { ...data, timeline: data.timeline.map((e) => ({ ...e, time: new Date(e.time) })) }; +} diff --git a/packages/react/src/components/NearbyStations.stories.tsx b/packages/react/src/components/NearbyStations.stories.tsx new file mode 100644 index 00000000..0504bbac --- /dev/null +++ b/packages/react/src/components/NearbyStations.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../provider.js"; +import { NearbyStations } from "./NearbyStations.js"; + +const meta: Meta = { + title: "Components/NearbyStations", + component: NearbyStations, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const ByStation: Story = { + args: { + stationId: "noaa/8443970", + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const ByPosition: Story = { + args: { + latitude: 42.3541, + longitude: -71.0495, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const LimitedResults: Story = { + args: { + stationId: "noaa/8443970", + maxResults: 3, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const Loading: Story = { + args: { + stationId: "noaa/8443970", + onStationSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + stationId: "nonexistent/station", + onStationSelect: (station) => console.log("Selected:", station), + }, +}; diff --git a/packages/react/src/components/NearbyStations.tsx b/packages/react/src/components/NearbyStations.tsx new file mode 100644 index 00000000..395514d0 --- /dev/null +++ b/packages/react/src/components/NearbyStations.tsx @@ -0,0 +1,156 @@ +import { useStation } from "../hooks/use-station.js"; +import { useNearbyStations } from "../hooks/use-nearby-stations.js"; +import { useNeapsConfig } from "../provider.js"; +import { formatDistance } from "../utils/format.js"; +import type { StationSummary } from "../types.js"; + +export interface NearbyStationsPositionProps { + latitude: number; + longitude: number; + stationId?: undefined; +} + +export interface NearbyStationsStationProps { + stationId: string; + latitude?: undefined; + longitude?: undefined; +} + +export type NearbyStationsProps = (NearbyStationsPositionProps | NearbyStationsStationProps) & { + maxResults?: number; + onStationSelect?: (station: StationSummary) => void; + /** Called when the user hovers over a station item. */ + onHover?: (station: StationSummary) => void; + /** Called when the user stops hovering over a station item. */ + onHoverEnd?: (station: StationSummary) => void; + className?: string; +}; + +export function NearbyStations(props: NearbyStationsProps) { + if (props.stationId) { + return ; + } + return ( + + ); +} + +function NearbyFromStation({ + stationId, + maxResults, + onStationSelect, + onHover, + onHoverEnd, + className, +}: { + stationId: string; + maxResults?: number; + onStationSelect?: (station: StationSummary) => void; + onHover?: (station: StationSummary) => void; + onHoverEnd?: (station: StationSummary) => void; + className?: string; +}) { + const station = useStation(stationId); + + if (station.isLoading) + return
Loading...
; + if (station.error) + return
{station.error.message}
; + + return ( + + ); +} + +function NearbyFromPosition({ + latitude, + longitude, + excludeId, + maxResults = 5, + onStationSelect, + onHover, + onHoverEnd, + className, +}: { + latitude: number; + longitude: number; + excludeId?: string; + maxResults?: number; + onStationSelect?: (station: StationSummary) => void; + onHover?: (station: StationSummary) => void; + onHoverEnd?: (station: StationSummary) => void; + className?: string; +}) { + const config = useNeapsConfig(); + const { + data: stations = [], + isLoading, + error, + } = useNearbyStations({ + latitude, + longitude, + maxResults: excludeId ? maxResults + 1 : maxResults, + }); + + if (isLoading) + return ( +
+ Loading nearby stations... +
+ ); + if (error) return
{error.message}
; + + return ( +
    + {stations + .filter((station) => station.id !== excludeId) + .slice(0, maxResults) + .map((station) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/packages/react/src/components/StationDisclaimers.tsx b/packages/react/src/components/StationDisclaimers.tsx new file mode 100644 index 00000000..d09ddbec --- /dev/null +++ b/packages/react/src/components/StationDisclaimers.tsx @@ -0,0 +1,10 @@ +export interface StationDisclaimersProps { + disclaimers?: string; + className?: string; +} + +export function StationDisclaimers({ disclaimers, className }: StationDisclaimersProps) { + if (!disclaimers) return null; + + return

{disclaimers}

; +} diff --git a/packages/react/src/components/StationSearch.stories.tsx b/packages/react/src/components/StationSearch.stories.tsx new file mode 100644 index 00000000..758ea5c0 --- /dev/null +++ b/packages/react/src/components/StationSearch.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../provider.js"; +import { StationSearch } from "./StationSearch.js"; + +const meta: Meta = { + title: "Components/StationSearch", + component: StationSearch, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + }, +}; + +export const CustomPlaceholder: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + placeholder: "Find a tide station...", + }, +}; + +export const Loading: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const WithRecentSearches: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + }, + play: () => { + // Seed localStorage with recent searches for this story + const recent = [ + { id: "noaa/8443970", name: "Boston, MA", region: "Massachusetts", country: "US" }, + { id: "noaa/8518750", name: "The Battery, NY", region: "New York", country: "US" }, + { id: "noaa/9414290", name: "San Francisco, CA", region: "California", country: "US" }, + ]; + localStorage.setItem("neaps-recent-searches", JSON.stringify(recent)); + }, +}; diff --git a/packages/react/src/components/StationSearch.tsx b/packages/react/src/components/StationSearch.tsx new file mode 100644 index 00000000..684f14c2 --- /dev/null +++ b/packages/react/src/components/StationSearch.tsx @@ -0,0 +1,240 @@ +import { useState, useRef, useCallback, useEffect, useId, type KeyboardEvent } from "react"; + +import { useStations } from "../hooks/use-stations.js"; +import type { StationSummary } from "../types.js"; + +const RECENT_KEY = "neaps-recent-searches"; +const MAX_RECENT = 5; + +interface RecentSearch { + id: string; + name: string; + region?: string; + country: string; +} + +function getRecentSearches(): RecentSearch[] { + try { + const raw = localStorage.getItem(RECENT_KEY); + return raw ? (JSON.parse(raw) as RecentSearch[]) : []; + } catch { + return []; + } +} + +function saveRecentSearch(station: StationSummary): void { + try { + const recent = getRecentSearches().filter((r) => r.id !== station.id); + recent.unshift({ + id: station.id, + name: station.name, + region: station.region, + country: station.country, + }); + localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT))); + } catch { + // Ignore localStorage errors + } +} + +export interface StationSearchProps { + onSelect: (station: StationSummary) => void; + placeholder?: string; + className?: string; +} + +export function StationSearch({ + onSelect, + placeholder = "Search stations...", + className, +}: StationSearchProps) { + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [recentSearches, setRecentSearches] = useState([]); + const instanceId = useId(); + const listboxId = `${instanceId}-results`; + const inputRef = useRef(null); + const listRef = useRef(null); + + // Load recent searches on mount + useEffect(() => { + setRecentSearches(getRecentSearches()); + }, []); + + // Debounce the query + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 300); + return () => clearTimeout(timer); + }, [query]); + + const { data: results = [] } = useStations( + debouncedQuery.length >= 2 ? { query: debouncedQuery } : {}, + ); + + const showResults = isOpen && debouncedQuery.length >= 2 && results.length > 0; + const showRecent = isOpen && query.length === 0 && recentSearches.length > 0; + + const handleSelect = useCallback( + (station: StationSummary) => { + setQuery(station.name); + setIsOpen(false); + setActiveIndex(-1); + saveRecentSearch(station); + setRecentSearches(getRecentSearches()); + onSelect(station); + }, + [onSelect], + ); + + const handleRecentSelect = useCallback( + (recent: RecentSearch) => { + setQuery(recent.name); + setIsOpen(false); + setActiveIndex(-1); + onSelect({ + id: recent.id, + name: recent.name, + region: recent.region, + country: recent.country, + latitude: 0, + longitude: 0, + continent: "", + timezone: "", + type: "reference", + }); + }, + [onSelect], + ); + + const dropdownItems = showResults ? results : []; + const totalItems = showRecent ? recentSearches.length : dropdownItems.length; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!showResults && !showRecent) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, totalItems - 1)); + break; + case "ArrowUp": + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + break; + case "Enter": + e.preventDefault(); + if (activeIndex >= 0 && activeIndex < totalItems) { + if (showRecent) { + handleRecentSelect(recentSearches[activeIndex]); + } else { + handleSelect(dropdownItems[activeIndex]); + } + } + break; + case "Escape": + setIsOpen(false); + setActiveIndex(-1); + break; + } + }, + [ + showResults, + showRecent, + dropdownItems, + recentSearches, + activeIndex, + totalItems, + handleSelect, + handleRecentSelect, + ], + ); + + return ( +
+ { + setQuery(e.target.value); + setIsOpen(true); + setActiveIndex(-1); + }} + onFocus={() => setIsOpen(true)} + onBlur={() => { + // Delay to allow click on result + setTimeout(() => setIsOpen(false), 200); + }} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full px-3 py-2 border border-(--neaps-border) rounded-lg bg-(--neaps-bg) text-(--neaps-text) text-sm outline-none transition-colors focus:border-(--neaps-primary) focus:ring-3 focus:ring-(--neaps-primary)/20" + role="combobox" + aria-expanded={showResults || showRecent} + aria-controls={listboxId} + aria-activedescendant={activeIndex >= 0 ? `${instanceId}-option-${activeIndex}` : undefined} + autoComplete="off" + /> + {showRecent && ( +
    +
  • + Recent +
  • + {recentSearches.map((recent, i) => ( +
  • handleRecentSelect(recent)} + > + {recent.name} + + {[recent.region, recent.country].filter(Boolean).join(", ")} + +
  • + ))} +
+ )} + {showResults && ( +
    + {results.map((station, i) => ( +
  • handleSelect(station)} + > + {station.name} + + {[station.region, station.country].filter(Boolean).join(", ")} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/packages/react/src/components/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx new file mode 100644 index 00000000..50aa300e --- /dev/null +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../provider.js"; +import { StationsMap } from "./StationsMap.js"; + +const meta: Meta = { + title: "Components/StationsMap", + component: StationsMap, + parameters: { + layout: "fullscreen", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const USEastCoast: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + initialViewState: { longitude: -71.05, latitude: 42.36, zoom: 8 }, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const HighZoom: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + initialViewState: { longitude: -71.05, latitude: 42.36, zoom: 12 }, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const Mini: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + initialViewState: { + latitude: 40.6067008972168, + longitude: -74.05500030517578, + zoom: 11, + }, + focusStation: "noaa/8519024", + clustering: false, + showGeolocation: false, + className: "aspect-video rounded-lg overflow-hidden", + }, + parameters: { + layout: "centered", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Loading: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + onStationSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + onStationSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx new file mode 100644 index 00000000..9dba5d3a --- /dev/null +++ b/packages/react/src/components/StationsMap.tsx @@ -0,0 +1,454 @@ +import { + useState, + useCallback, + useMemo, + forwardRef, + type ComponentProps, + type ReactNode, +} from "react"; +import { + Map, + Source, + Layer, + Popup, + type MapRef, + type ViewStateChangeEvent, + type MapLayerMouseEvent, + type MapEvent, +} from "react-map-gl/maplibre"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { keepPreviousData } from "@tanstack/react-query"; + +import { useStation } from "../hooks/use-station.js"; +import { useStations } from "../hooks/use-stations.js"; +import { useDebouncedCallback } from "../hooks/use-debounced-callback.js"; +import { useThemeColors } from "../hooks/use-theme-colors.js"; +import { TideConditions } from "./TideConditions.js"; +import type { StationSummary } from "../types.js"; + +// Props that StationsMap manages internally and cannot be overridden +type ManagedMapProps = "onMove" | "onClick" | "interactiveLayerIds" | "style" | "cursor"; + +export interface StationsMapProps extends Omit, ManagedMapProps> { + onStationSelect?: (station: StationSummary) => void; + onBoundsChange?: (bounds: { north: number; south: number; east: number; west: number }) => void; + /** Whether to show the geolocation button. Defaults to true. */ + showGeolocation?: boolean; + /** Station ID to highlight with a larger marker. The marker is never absorbed by clusters. */ + focusStation?: string; + /** Enable clustering of station markers. Defaults to true. */ + clustering?: boolean; + /** Max zoom level at which clusters are generated. Defaults to 14. */ + clusterMaxZoom?: number; + /** Cluster radius in pixels. Defaults to 50. */ + clusterRadius?: number; + /** + * Controls popup content when clicking a station. + * - `"preview"` (default): shows station name + next tide (fetched), only at zoom >= 10 + * - `"simple"`: shows station name + region, no API call, no zoom gate + * - function: receives station summary, return ReactNode, no zoom gate + * - `false`: disables popups entirely (onStationSelect still fires) + */ + popupContent?: "preview" | "simple" | ((station: StationSummary) => ReactNode) | false; + /** CSS class applied to the outer wrapper div. */ + className?: string; +} + +function stationsToGeoJSON(stations: StationSummary[]): GeoJSON.FeatureCollection { + return { + type: "FeatureCollection", + features: stations.map(({ longitude, latitude, ...properties }) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [longitude, latitude], + }, + properties, + })), + }; +} + +function StationPreviewCard({ stationId }: { stationId: string }) { + return ; +} + +export const StationsMap = forwardRef(function StationsMap( + { + onStationSelect, + onBoundsChange, + focusStation, + showGeolocation = true, + clustering = true, + clusterMaxZoom: clusterMaxZoomProp = 7, + clusterRadius: clusterRadiusProp = 50, + popupContent = "preview", + children, + className, + ...mapProps + }, + ref, +) { + const [viewState, setViewState] = useState(mapProps.initialViewState ?? {}); + const [bbox, setBbox] = useState<[number, number, number, number] | null>(null); + const debouncedSetBbox = useDebouncedCallback(setBbox, 200); + const [selectedStation, setSelectedStation] = useState(null); + + const colors = useThemeColors(); + + const { + data: stations = [], + isLoading, + isError, + } = useStations(bbox ? { bbox: bbox.join(",") } : {}, { + enabled: bbox !== null, + placeholderData: keepPreviousData, + }); + + // Focus station: fetch if not in loaded stations, build separate GeoJSON + const focusStationInList = focusStation ? stations.find((s) => s.id === focusStation) : undefined; + const { data: fetchedFocusStation } = useStation( + focusStation && !focusStationInList ? focusStation : undefined, + ); + const focusStationData: StationSummary | undefined = + focusStationInList ?? (fetchedFocusStation as StationSummary | undefined); + + const geojson = useMemo( + () => + stationsToGeoJSON(focusStation ? stations.filter((s) => s.id !== focusStation) : stations), + [stations, focusStation], + ); + + const focusGeoJSON: GeoJSON.FeatureCollection | null = useMemo(() => { + if (!focusStationData) return null; + return stationsToGeoJSON([focusStationData]); + }, [focusStationData]); + + const updateBbox = useCallback( + (e: MapEvent) => { + const map = e.target; + const mapBounds = map.getBounds(); + const sw = mapBounds.getSouthWest(); + const ne = mapBounds.getNorthEast(); + debouncedSetBbox([sw.lng, sw.lat, ne.lng, ne.lat]); + onBoundsChange?.({ + north: mapBounds.getNorth(), + south: mapBounds.getSouth(), + east: mapBounds.getEast(), + west: mapBounds.getWest(), + }); + }, + [onBoundsChange, debouncedSetBbox], + ); + + const handleMove = useCallback( + (e: ViewStateChangeEvent) => { + setViewState(e.viewState); + updateBbox(e); + }, + [updateBbox], + ); + + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + const feature = e.features?.[0]; + if (!feature) return; + + const props = feature.properties; + + // Cluster click → zoom in + if (props?.cluster) { + setViewState((prev) => ({ + ...prev, + longitude: (feature.geometry as GeoJSON.Point).coordinates[0], + latitude: (feature.geometry as GeoJSON.Point).coordinates[1], + zoom: Math.min((prev.zoom ?? 3) + 2, 18), + })); + return; + } + + // Station point click + if (props?.id) { + const coords = (feature.geometry as GeoJSON.Point).coordinates; + const station = { + ...props, + latitude: coords[1], + longitude: coords[0], + } as StationSummary; + + if (popupContent !== false) { + setSelectedStation(station); + } + + onStationSelect?.(station); + } + }, + [onStationSelect, popupContent], + ); + + const handleLocateMe = useCallback(() => { + navigator.geolocation.getCurrentPosition( + (pos) => { + setViewState((prev) => ({ + ...prev, + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + zoom: Math.max(prev.zoom ?? 0, 10), + })); + }, + () => { + // Silently ignore geolocation errors + }, + ); + }, []); + + return ( +
+ + + {/* Clustered circles */} + + + {/* Cluster count labels */} + + + {/* Unclustered station points — colored by type */} + + + {/* Station name labels at higher zoom */} + + + + {/* Focus station — rendered above clusters, never absorbed */} + {focusGeoJSON && ( + + + + + )} + + {/* Station popup */} + {selectedStation && popupContent !== false && ( + setSelectedStation(null)} + closeOnClick={false} + closeButton={false} + > +
+ {typeof popupContent === "function" ? ( + popupContent(selectedStation) + ) : ( + <> +
+
+ {selectedStation.name} +
+ +
+ {popupContent === "simple" ? ( +
+ {[selectedStation.region, selectedStation.country].filter(Boolean).join(", ")} +
+ ) : ( + + )} + + )} +
+
+ )} +
+ + {/* Locate me button */} + {showGeolocation && "geolocation" in navigator && ( + + )} + + {isError && ( +
+
+ + + + + + Failed to load stations +
+
+ )} + + {isLoading && ( +
+
+ + + + Loading stations… +
+
+ )} + + {children} +
+ ); +}); diff --git a/packages/react/src/components/TideConditions.stories.tsx b/packages/react/src/components/TideConditions.stories.tsx new file mode 100644 index 00000000..f0e877f0 --- /dev/null +++ b/packages/react/src/components/TideConditions.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TideConditions } from "./TideConditions.js"; + +const STATION_ID = "noaa/8443970"; + +const meta: Meta = { + title: "Components/TideConditions", + component: TideConditions, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { id: STATION_ID }, +}; + +export const NoDate: Story = { + args: { id: STATION_ID, showDate: false }, +}; + +export const NoData: Story = { + args: { + timeline: [], + extremes: [], + units: "feet", + timezone: "UTC", + }, +}; diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx new file mode 100644 index 00000000..56b5b2b9 --- /dev/null +++ b/packages/react/src/components/TideConditions.tsx @@ -0,0 +1,225 @@ +import { useMemo } from "react"; +import { useCurrentLevel } from "../hooks/use-current-level.js"; +import { useTimeline } from "../hooks/use-timeline.js"; +import { useExtremes } from "../hooks/use-extremes.js"; +import { useNeapsConfig } from "../provider.js"; +import { formatLevel } from "../utils/format.js"; +import { TideCycleGraph } from "./TideCycleGraph.js"; +import type { Extreme, TimelineEntry, Units } from "../types.js"; +import { HALF_TIDE_CYCLE_MS } from "../constants.js"; + +interface TideConditionsDataProps { + timeline: TimelineEntry[]; + extremes: Extreme[]; + units: Units; + timezone: string; +} + +interface TideConditionsFetchProps { + id: string; +} + +export type TideConditionsProps = (TideConditionsDataProps | TideConditionsFetchProps) & { + showDate?: boolean; + className?: string; +}; + +const NEAR_EXTREME_MS = 10 * 60 * 1000; + +function getNearestExtremes(extremes: Extreme[]): { + current: Extreme | null; + next: Extreme | null; +} { + const now = Date.now(); + const nextIdx = extremes.findIndex((e) => e.time.getTime() > now); + if (nextIdx === -1) return { current: null, next: null }; + + const next = extremes[nextIdx]; + // If we're within 10 minutes of the next extreme, treat it as "now" + if (next.time.getTime() - now <= NEAR_EXTREME_MS) { + return { current: next, next: extremes[nextIdx + 1] ?? null }; + } + + // Also check the previous extreme + if (nextIdx > 0) { + const prev = extremes[nextIdx - 1]; + if (now - prev.time.getTime() <= NEAR_EXTREME_MS) { + return { current: prev, next }; + } + } + + return { current: null, next }; +} + +type TideState = "rising" | "falling" | "high" | "low"; + +const STATE_ICON: Record = { + rising: { icon: "↗", label: "Rising", color: "text-(--neaps-high)" }, + falling: { icon: "↘", label: "Falling", color: "text-(--neaps-low)" }, + high: { icon: "⤒", label: "High tide", color: "text-(--neaps-high)" }, + low: { icon: "⤓", label: "Low tide", color: "text-(--neaps-low)" }, +}; + +export function WaterLevelAtTime({ + label, + level, + time, + units, + locale, + state, + variant, +}: { + label: string; + level: number; + time: Date; + units: Units; + locale: string; + state?: TideState; + variant?: "left" | "right"; +}) { + const stateIcon = state ? STATE_ICON[state] : null; + return ( +
+
+ {label} + {stateIcon && ( + + {stateIcon.icon} + + )} +
+ + {formatLevel(level, units)} + + + {time.toLocaleString(locale, { + timeStyle: "short", + })} + +
+ ); +} + +function TideConditionsStatic({ + timeline, + extremes, + units, + timezone, + showDate, + className, +}: TideConditionsDataProps & { showDate: boolean; className?: string }) { + const { locale } = useNeapsConfig(); + const currentLevel = useCurrentLevel(timeline); + const { current: nearExtreme, next: nextExtreme } = getNearestExtremes(extremes); + + if (!currentLevel) { + return ( +
+

No tide data available

+
+ ); + } + + return ( +
+
+ + {showDate && ( +
+

+ {currentLevel.time.toLocaleString(locale, { + dateStyle: "medium", + timeZone: timezone, + })} +

+
+ )} +
+ + + {nextExtreme ? ( + + ) : ( +
+ )} +
+
+
+ ); +} + +function TideConditionsFetcher({ + id, + showDate, + className, +}: TideConditionsFetchProps & { showDate: boolean; className?: string }) { + const config = useNeapsConfig(); + const [start, end] = useMemo(() => { + const now = Date.now(); + return [ + new Date(now - HALF_TIDE_CYCLE_MS).toISOString(), + new Date(now + HALF_TIDE_CYCLE_MS).toISOString(), + ]; + }, []); + + const timeline = useTimeline({ id, start, end }); + const extremes = useExtremes({ id, start, end }); + + if (timeline.isLoading || extremes.isLoading) { + return ( +
+
+ Loading... +
+
+ ); + } + + if (!timeline.data || !extremes.data) return null; + + return ( + + ); +} + +export function TideConditions({ showDate = true, className, ...props }: TideConditionsProps) { + if ("id" in props) { + return ; + } + return ; +} diff --git a/packages/react/src/components/TideCycleGraph.tsx b/packages/react/src/components/TideCycleGraph.tsx new file mode 100644 index 00000000..83d114c2 --- /dev/null +++ b/packages/react/src/components/TideCycleGraph.tsx @@ -0,0 +1,173 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { Group } from "@visx/group"; +import { curveNatural } from "@visx/curve"; + +import { interpolateLevel } from "../hooks/use-current-level.js"; +import { useTideScales, type Margin } from "../hooks/use-tide-scales.js"; +import type { Extreme, TimelineEntry } from "../types.js"; +import { HALF_TIDE_CYCLE_MS } from "../constants.js"; +const MARGIN: Margin = { top: 0, right: 0, bottom: 0, left: 0 }; + +export interface TideCycleGraphProps { + timeline: TimelineEntry[]; + extremes: Extreme[]; + className?: string; +} + +const getX = (d: TimelineEntry) => d.time.getTime(); +const getY = (d: TimelineEntry) => d.level; + +function TideCycleGraphChart({ + timeline, + extremes, + currentLevel, + width, + height, +}: { + timeline: TimelineEntry[]; + extremes: Extreme[]; + currentLevel: TimelineEntry | null; + width: number; + height: number; +}) { + const { xScale, yScale, innerW, innerH } = useTideScales({ + timeline, + extremes, + width, + height, + margin: MARGIN, + }); + + if (innerW <= 0 || innerH <= 0) return null; + + return ( + + + + + + + + + + + xScale(getX(d))} + y={(d) => yScale(getY(d))} + yScale={yScale} + curve={curveNatural} + fill="url(#cycle-gradient)" + opacity={0.3} + /> + xScale(getX(d))} + y={(d) => yScale(getY(d))} + curve={curveNatural} + stroke="var(--neaps-primary)" + strokeWidth={2} + strokeOpacity={0.5} + /> + + {extremes.map((e, i) => ( + + ))} + + {currentLevel && ( + + )} + + + ); +} + +export function TideCycleGraph({ timeline, extremes, className }: TideCycleGraphProps) { + const containerRef = useRef(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setWidth(entry.contentRect.width); + setHeight(entry.contentRect.height); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const windowStart = now - HALF_TIDE_CYCLE_MS; + const windowEnd = now + HALF_TIDE_CYCLE_MS; + + const windowTimeline = useMemo( + () => + timeline.filter((e) => { + const t = e.time.getTime(); + return t >= windowStart && t <= windowEnd; + }), + [timeline, windowStart, windowEnd], + ); + + const windowExtremes = useMemo( + () => + extremes.filter((e) => { + const t = e.time.getTime(); + return t >= windowStart && t <= windowEnd; + }), + [extremes, windowStart, windowEnd], + ); + + const currentLevel = useMemo(() => interpolateLevel(windowTimeline, now), [windowTimeline, now]); + + if (!windowTimeline.length) return null; + + return ( +
+ {width > 0 && height > 0 && ( + + )} +
+ ); +} diff --git a/packages/react/src/components/TideGraph/NightBands.tsx b/packages/react/src/components/TideGraph/NightBands.tsx new file mode 100644 index 00000000..6da3fed6 --- /dev/null +++ b/packages/react/src/components/TideGraph/NightBands.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import type { TideXScale } from "../../hooks/use-tide-scales.js"; +import { getNightIntervals } from "../../utils/sun.js"; +import { HEIGHT, MARGIN } from "./constants.js"; + +export function NightBands({ + xScale, + latitude, + longitude, +}: { + xScale: TideXScale; + latitude?: number; + longitude?: number; +}) { + const intervals = useMemo(() => { + if (latitude == null || longitude == null) return []; + const [start, end] = xScale.domain(); + return getNightIntervals(latitude, longitude, start.getTime(), end.getTime()); + }, [latitude, longitude, xScale]); + + return ( + <> + {intervals.map(({ start, end }, i) => { + const x1 = xScale(start); + const x2 = xScale(end); + return ( + + ); + })} + + ); +} diff --git a/packages/react/src/components/TideGraph/TideGraph.stories.tsx b/packages/react/src/components/TideGraph/TideGraph.stories.tsx new file mode 100644 index 00000000..ff2bcc7b --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraph.stories.tsx @@ -0,0 +1,80 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../../provider.js"; +import { TideGraph } from "./TideGraph.js"; + +const meta: Meta = { + title: "Components/TideGraph", + component: TideGraph, + argTypes: { + id: { control: "text" }, + pxPerDay: { control: { type: "range", min: 100, max: 400, step: 25 } }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + id: "noaa/8443970", + }, +}; + +export const DenseScale: Story = { + args: { + id: "noaa/8443970", + pxPerDay: 100, + }, +}; + +export const WideScale: Story = { + args: { + id: "noaa/8443970", + pxPerDay: 350, + }, +}; + +export const MobileWidth: Story = { + args: { + id: "noaa/8443970", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const DesktopWidth: Story = { + args: { + id: "noaa/8443970", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Loading: Story = { + args: { + id: "noaa/8443970", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + id: "nonexistent/station", + }, +}; diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx new file mode 100644 index 00000000..7b897d07 --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useTooltip } from "@visx/tooltip"; + +import { useTideChunks } from "../../hooks/use-tide-chunks.js"; +import { useCurrentLevel } from "../../hooks/use-current-level.js"; +import { useTideScales } from "../../hooks/use-tide-scales.js"; +import { TideGraphChart } from "./TideGraphChart.js"; +import { YAxisOverlay } from "./YAxisOverlay.js"; +import { HEIGHT, MARGIN, MS_PER_DAY, PX_PER_DAY_DEFAULT } from "./constants.js"; +import type { TimelineEntry } from "../../types.js"; + +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +export interface TideGraphProps { + id: string; + pxPerDay?: number; + className?: string; +} + +export function TideGraph({ id, pxPerDay = PX_PER_DAY_DEFAULT, className }: TideGraphProps) { + const scrollRef = useRef(null); + const prevDataStartRef = useRef(null); + const prevScrollWidthRef = useRef(null); + const hasScrolledToNow = useRef(false); + + const { + timeline, + extremes, + dataStart, + dataEnd, + yDomain, + loadPrevious, + loadNext, + isLoadingPrevious, + isLoadingNext, + isLoading, + error, + station, + timezone, + units, + } = useTideChunks({ id }); + + const totalMs = dataEnd - dataStart; + const totalDays = totalMs / MS_PER_DAY; + const svgWidth = Math.max(1, totalDays * pxPerDay + MARGIN.left + MARGIN.right); + const innerW = svgWidth - MARGIN.left - MARGIN.right; + + // Y-axis scales (for the overlay) + const { yScale } = useTideScales({ + timeline, + extremes, + width: svgWidth, + height: HEIGHT, + margin: MARGIN, + yDomainOverride: yDomain, + domainOverride: { xMin: dataStart, xMax: dataEnd }, + }); + + const narrowRange = useMemo(() => { + const range = yDomain[1] - yDomain[0]; + return range > 0 && range < 3; + }, [yDomain]); + + const unitSuffix = units === "feet" ? "ft" : "m"; + + // Annotation state: entries, not timestamps + const currentLevel = useCurrentLevel(timeline); + const { tooltipData, showTooltip, hideTooltip } = useTooltip(); + const [pinnedEntry, setPinnedEntry] = useState(null); + const activeEntry = tooltipData ?? pinnedEntry ?? currentLevel; + + const handleSelect = useCallback( + (entry: TimelineEntry | null, sticky?: boolean) => { + if (sticky) setPinnedEntry(entry); + else if (entry) showTooltip({ tooltipData: entry }); + else hideTooltip(); + }, + [showTooltip, hideTooltip], + ); + + // Position of "now" in SVG coordinates (for today-button visibility) + const nowMs = currentLevel ? currentLevel.time.getTime() : null; + const nowPx = useMemo(() => { + if (nowMs === null) return null; + return ((nowMs - dataStart) / totalMs) * innerW + MARGIN.left; + }, [nowMs, dataStart, totalMs, innerW]); + + // Scroll to "now" on initial data load + useEffect(() => { + if (hasScrolledToNow.current || !timeline.length || !scrollRef.current) return; + const container = scrollRef.current; + const nowPx = ((Date.now() - dataStart) / totalMs) * innerW + MARGIN.left; + container.scrollLeft = nowPx - container.clientWidth / 2; + hasScrolledToNow.current = true; + prevDataStartRef.current = dataStart; + prevScrollWidthRef.current = container.scrollWidth; + }, [timeline.length, dataStart, totalMs, innerW]); + + // Preserve scroll position when chunks prepend (leftward) + useIsomorphicLayoutEffect(() => { + const container = scrollRef.current; + if (!container || prevDataStartRef.current === null || prevScrollWidthRef.current === null) + return; + if (dataStart < prevDataStartRef.current) { + const widthAdded = container.scrollWidth - prevScrollWidthRef.current; + container.scrollLeft += widthAdded; + } + prevDataStartRef.current = dataStart; + prevScrollWidthRef.current = container.scrollWidth; + }, [dataStart]); + + // Sentinel-based edge detection + const leftSentinelRef = useRef(null); + const rightSentinelRef = useRef(null); + + useEffect(() => { + const container = scrollRef.current; + const leftSentinel = leftSentinelRef.current; + const rightSentinel = rightSentinelRef.current; + if (!container || !leftSentinel || !rightSentinel) return; + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + if (entry.target === leftSentinel) loadPrevious(); + if (entry.target === rightSentinel) loadNext(); + } + }, + { root: container, rootMargin: `0px ${pxPerDay}px` }, + ); + + observer.observe(leftSentinel); + observer.observe(rightSentinel); + return () => observer.disconnect(); + }, [loadPrevious, loadNext, pxPerDay]); + + // Today button direction + const [todayDirection, setTodayDirection] = useState<"left" | "right" | null>(null); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + + function onScroll() { + const sl = container!.scrollLeft; + const w = container!.clientWidth; + + if (pinnedEntry && nowMs !== null) { + const pinnedMs = pinnedEntry.time.getTime(); + setTodayDirection(pinnedMs < nowMs ? "right" : "left"); + } else if (nowPx !== null) { + const nowVx = nowPx - sl; + if (nowVx < 60) setTodayDirection("left"); + else if (nowVx > w - 10) setTodayDirection("right"); + else setTodayDirection(null); + } else { + setTodayDirection(null); + } + + // Clear pinned entry when it scrolls far out of view + if (pinnedEntry) { + const pinnedMs = pinnedEntry.time.getTime(); + const pinnedPx = ((pinnedMs - dataStart) / totalMs) * innerW + MARGIN.left; + const pvx = pinnedPx - sl; + if (pvx < -w || pvx > 2 * w) { + setPinnedEntry(null); + } + } + } + + onScroll(); + container.addEventListener("scroll", onScroll, { passive: true }); + return () => container.removeEventListener("scroll", onScroll); + }, [nowPx, nowMs, pinnedEntry, dataStart, totalMs, innerW]); + + // Scroll to now handler + const scrollToNow = useCallback(() => { + setPinnedEntry(null); + const container = scrollRef.current; + if (!container) return; + const nowPx = ((Date.now() - dataStart) / totalMs) * innerW + MARGIN.left; + container.scrollTo({ left: nowPx - container.clientWidth / 2, behavior: "smooth" }); + }, [dataStart, totalMs, innerW]); + + if (isLoading && !timeline.length) { + return ( +
+ Loading tide data... +
+ ); + } + + if (error && !timeline.length) { + return ( +
+ {error.message} +
+ ); + } + + return ( +
+
+ {/* Scrollable chart area */} +
+
+ {/* Left sentinel */} +
+ + + + {/* Right sentinel */} +
+
+ + {/* Edge loading indicators */} + {isLoadingPrevious && ( +
+ Loading... +
+ )} + {isLoadingNext && ( +
+ Loading... +
+ )} +
+ + {/* Right edge fade */} +
+ + {/* Y-axis overlay (fixed left) */} + + + {/* Today button — fades in when now is off-screen or a point is pinned */} + +
+
+ ); +} diff --git a/packages/react/src/components/TideGraph/TideGraphChart.tsx b/packages/react/src/components/TideGraph/TideGraphChart.tsx new file mode 100644 index 00000000..ce4d3641 --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraphChart.tsx @@ -0,0 +1,353 @@ +import { useCallback, useId, useMemo } from "react"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { AxisTop } from "@visx/axis"; +import { Group } from "@visx/group"; +import { curveNatural } from "@visx/curve"; +import { localPoint } from "@visx/event"; +import { bisector } from "d3-array"; + +import { formatLevel, formatTime } from "../../utils/format.js"; +import { useTideScales } from "../../hooks/use-tide-scales.js"; +import { NightBands } from "./NightBands.js"; +import { getDaylightMidpoints } from "../../utils/sun.js"; +import { HEIGHT, MARGIN } from "./constants.js"; +import type { TimelineEntry, Extreme, Units } from "../../types.js"; +import { useNeapsConfig } from "../../provider.js"; + +const timelineBisector = bisector((d) => d.time.getTime()).left; + +export function TideGraphChart({ + timeline, + extremes, + timezone, + units, + svgWidth, + yDomainOverride, + latitude, + longitude, + className, + activeEntry, + onSelect, +}: { + timeline: TimelineEntry[]; + extremes: Extreme[]; + timezone: string; + units: Units; + svgWidth: number; + yDomainOverride?: [number, number]; + latitude?: number; + longitude?: number; + className?: string; + activeEntry?: TimelineEntry | null; + onSelect: (entry: TimelineEntry | null, sticky?: boolean) => void; +}) { + const gradientId = useId(); + const { locale } = useNeapsConfig(); + + const { xScale, yScale, innerW, innerH } = useTideScales({ + timeline, + extremes, + width: svgWidth, + height: HEIGHT, + margin: MARGIN, + yDomainOverride, + }); + + const findNearestEntry = useCallback( + (event: React.PointerEvent): TimelineEntry | null => { + const point = localPoint(event); + if (!point) return null; + const x0 = xScale.invert(point.x - MARGIN.left).getTime(); + const idx = timelineBisector(timeline, x0, 1); + const d0 = timeline[idx - 1]; + const d1 = timeline[idx]; + if (!d0) return null; + return d1 && x0 - d0.time.getTime() > d1.time.getTime() - x0 ? d1 : d0; + }, + [xScale, timeline], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType === "touch") return; + const d = findNearestEntry(event); + if (d) onSelect(d); + }, + [findNearestEntry, onSelect], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== "touch") return; + const d = findNearestEntry(event); + if (d) onSelect?.(d, true); + }, + [findNearestEntry, onSelect], + ); + + const zeroY = yScale(0); + // A scale whose range()[0] is the zero line — used as AreaClosed baseline + const zeroBaseScale = useMemo(() => ({ range: () => [zeroY, 0] }) as typeof yScale, [zeroY]); + + const daylightMidpoints = useMemo(() => { + const [start, end] = xScale.domain(); + if (latitude != null && longitude != null) { + return getDaylightMidpoints(latitude, longitude, start.getTime(), end.getTime()); + } + // Fallback to noon when coordinates are unavailable + const dates: Date[] = []; + const d = new Date(start); + d.setHours(12, 0, 0, 0); + if (d.getTime() < start.getTime()) d.setDate(d.getDate() + 1); + while (d <= end) { + dates.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + return dates; + }, [xScale, latitude, longitude]); + + if (innerW <= 0 || svgWidth <= 0) return null; + + return ( + + + + + + + + + + + + + + + + + + + + + + {/* Zero reference line */} + + + {/* Area fill: positive (above zero) */} + xScale(d.time.getTime())} + y={(d) => yScale(d.level)} + yScale={zeroBaseScale} + curve={curveNatural} + fill={`url(#${gradientId})`} + clipPath={`url(#${gradientId}-clip-pos)`} + /> + {/* Area fill: negative (below zero) */} + xScale(d.time.getTime())} + y={(d) => yScale(d.level)} + yScale={zeroBaseScale} + curve={curveNatural} + fill={`url(#${gradientId}-neg)`} + clipPath={`url(#${gradientId}-clip-neg)`} + /> + xScale(d.time.getTime())} + y={(d) => yScale(d.level)} + curve={curveNatural} + stroke="var(--neaps-primary)" + strokeWidth={2} + /> + + {/* Extreme points + labels */} + {extremes.map((e) => { + const cx = xScale(e.time.getTime()); + const cy = yScale(e.level); + return ( + + + + {e.high ? ( + <> + + {formatTime(e.time, timezone, locale)} + + + {formatLevel(e.level, units)} + + + ⤒ + + + ) : ( + <> + + ⤓ + + + {formatLevel(e.level, units)} + + + {formatTime(e.time, timezone, locale)} + + + )} + + + ); + })} + + {/* Top axis — date ticks */} + {(() => { + const fmt = new Intl.DateTimeFormat(locale, { timeZone: timezone, month: "short" }); + const months = daylightMidpoints.map((dt) => fmt.format(dt)); + + return ( + { + const dt = new Date(v as Date); + const showMonth = i === 0 || months[i] !== months[i - 1]; + return dt.toLocaleDateString(locale, { + weekday: "short", + day: "numeric", + month: showMonth ? "short" : undefined, + timeZone: timezone, + }); + }} + stroke="var(--neaps-border)" + tickStroke="none" + tickLabelProps={{ + fill: "var(--neaps-text-muted)", + fontSize: 12, + fontWeight: 600, + textAnchor: "middle", + }} + /> + ); + })()} + + {/* Tooltip hit area */} + onSelect(null)} + /> + + {/* Active entry annotation */} + {activeEntry && + (() => { + const cx = xScale(activeEntry.time.getTime()); + const labelY = innerH / 2; + return ( + + + + + {formatTime(activeEntry.time, timezone, locale)} + + + {formatLevel(activeEntry.level, units)} + + + ); + })()} + + + ); +} diff --git a/packages/react/src/components/TideGraph/YAxisOverlay.tsx b/packages/react/src/components/TideGraph/YAxisOverlay.tsx new file mode 100644 index 00000000..c40989b4 --- /dev/null +++ b/packages/react/src/components/TideGraph/YAxisOverlay.tsx @@ -0,0 +1,46 @@ +import { AxisLeft } from "@visx/axis"; +import { Group } from "@visx/group"; + +import type { TideYScale } from "../../hooks/use-tide-scales.js"; +import { HEIGHT, MARGIN } from "./constants.js"; + +export function YAxisOverlay({ + yScale, + narrowRange, + unitSuffix, +}: { + yScale: TideYScale; + narrowRange: boolean; + unitSuffix: string; +}) { + return ( +
+ + + + `${narrowRange ? Number(v).toFixed(1) : Math.round(Number(v))} ${unitSuffix}` + } + tickLabelProps={{ + fill: "var(--neaps-text-muted)", + fontSize: 12, + textAnchor: "end", + dy: 4, + style: { fontVariantNumeric: "tabular-nums" }, + }} + /> + + +
+ ); +} diff --git a/packages/react/src/components/TideGraph/constants.ts b/packages/react/src/components/TideGraph/constants.ts new file mode 100644 index 00000000..1272dec2 --- /dev/null +++ b/packages/react/src/components/TideGraph/constants.ts @@ -0,0 +1,6 @@ +import type { Margin } from "../../hooks/use-tide-scales.js"; + +export const PX_PER_DAY_DEFAULT = 200; +export const HEIGHT = 300; +export const MARGIN: Margin = { top: 65, right: 0, bottom: 40, left: 60 }; +export const MS_PER_DAY = 24 * 60 * 60 * 1000; diff --git a/packages/react/src/components/TideGraph/index.ts b/packages/react/src/components/TideGraph/index.ts new file mode 100644 index 00000000..b8852bac --- /dev/null +++ b/packages/react/src/components/TideGraph/index.ts @@ -0,0 +1,4 @@ +export * from "./TideGraph.js"; +export * from "./TideGraphChart.js"; +export * from "./NightBands.js"; +export * from "./YAxisOverlay.js"; diff --git a/packages/react/src/components/TideSettings.tsx b/packages/react/src/components/TideSettings.tsx new file mode 100644 index 00000000..262fdb21 --- /dev/null +++ b/packages/react/src/components/TideSettings.tsx @@ -0,0 +1,137 @@ +import { useNeapsConfig, useUpdateConfig } from "../provider.js"; +import type { Station, Units } from "../types.js"; + +export interface TideSettingsProps { + station: Pick; + className?: string; +} + +function UnitSelect({ value, onChange }: { value: Units; onChange: (v: Units) => void }) { + return ( + + ); +} + +function DatumSelect({ + options, + defaultDatum, + value, + onChange, +}: { + options: string[]; + defaultDatum?: string; + value: string | undefined; + onChange: (v: string | undefined) => void; +}) { + return ( + + ); +} + +interface TimezoneOption { + value: string | undefined; + label: string; +} + +function buildTimezoneOptions(stationTimezone: string): TimezoneOption[] { + const browserTimezone = + typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : undefined; + + const options: TimezoneOption[] = [{ value: undefined, label: `Station (${stationTimezone})` }]; + + if (browserTimezone && browserTimezone !== stationTimezone) { + options.push({ value: browserTimezone, label: `Local (${browserTimezone})` }); + } + + if (stationTimezone !== "UTC" && browserTimezone !== "UTC") { + options.push({ value: "UTC", label: "UTC" }); + } + + return options; +} + +function TimezoneSelect({ + options, + value, + onChange, +}: { + options: TimezoneOption[]; + value: string | undefined; + onChange: (v: string | undefined) => void; +}) { + if (options.length <= 1) return null; + + return ( + + ); +} + +export function TideSettings({ station, className }: TideSettingsProps) { + const config = useNeapsConfig(); + const updateConfig = useUpdateConfig(); + + const datumOptions = Object.keys(station.datums); + const timezoneOptions = buildTimezoneOptions(station.timezone); + + return ( +
+ updateConfig({ units })} /> + + {datumOptions.length > 1 && ( + updateConfig({ datum })} + /> + )} + + updateConfig({ timezone })} + /> +
+ ); +} diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx new file mode 100644 index 00000000..a7d313d3 --- /dev/null +++ b/packages/react/src/components/TideStation.stories.tsx @@ -0,0 +1,99 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../provider.js"; +import { TideStation } from "./TideStation.js"; + +const meta: Meta = { + title: "Components/TideStation", + component: TideStation, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + id: "noaa/8443970", + }, +}; + +export const WidgetSize: Story = { + args: { + id: "noaa/8443970", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const MobileWidth: Story = { + args: { + id: "noaa/8443970", + showTable: true, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const DesktopSideBySide: Story = { + args: { + id: "noaa/8443970", + showGraph: true, + showTable: true, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const FrenchLocale: Story = { + args: { + id: "noaa/8443970", + showTable: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Loading: Story = { + args: { + id: "noaa/8443970", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + id: "nonexistent/station", + }, +}; diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx new file mode 100644 index 00000000..9cb73c19 --- /dev/null +++ b/packages/react/src/components/TideStation.tsx @@ -0,0 +1,83 @@ +import { useMemo } from "react"; + +import { useStation } from "../hooks/use-station.js"; +import { useExtremes } from "../hooks/use-extremes.js"; +import { useTimeline } from "../hooks/use-timeline.js"; +import { useNeapsConfig } from "../provider.js"; +import { TideConditions } from "./TideConditions.js"; +import { TideGraph } from "./TideGraph/index.js"; +import { TideTable } from "./TideTable.js"; +import type { Units } from "../types.js"; +import { TideStationHeader } from "./TideStationHeader.js"; +import { StationDisclaimers } from "./StationDisclaimers.js"; +import { TideSettings } from "./TideSettings.js"; +import { getDefaultRange } from "../utils/defaults.js"; + +export interface TideStationProps { + id: string; + showGraph?: boolean; + showTable?: boolean; + className?: string; +} + +export function TideStation({ + id, + showGraph = true, + showTable = true, + className, +}: TideStationProps) { + const config = useNeapsConfig(); + const range = useMemo(getDefaultRange, []); + + const station = useStation(id); + const timeline = useTimeline({ id, start: range.start, end: range.end }); + const extremes = useExtremes({ id, start: range.start, end: range.end }); + + if (station.isLoading || timeline.isLoading || extremes.isLoading) { + return ( +
+ Loading... +
+ ); + } + + if (station.error || timeline.error || extremes.error) { + const err = station.error ?? timeline.error ?? extremes.error; + return ( +
+ {err!.message} +
+ ); + } + + const s = station.data!; + const units: Units = timeline.data?.units ?? config.units; + const timezone = config.timezone ?? s.timezone; + const timelineData = timeline.data?.timeline ?? []; + const extremesData = extremes.data?.extremes ?? []; + + return ( +
+ + + + + + + {showGraph && } + + {showTable && } + + +
+ ); +} diff --git a/packages/react/src/components/TideStationHeader.tsx b/packages/react/src/components/TideStationHeader.tsx new file mode 100644 index 00000000..62337907 --- /dev/null +++ b/packages/react/src/components/TideStationHeader.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { CoordinateFormat } from "coordinate-format"; +import type { StationSummary } from "../types.js"; + +const coordFormatter = new CoordinateFormat("minutes", { precision: 2 }); + +export interface TideStationHeaderProps { + station: Pick; + className?: string; +} + +export function TideStationHeader({ station, className }: TideStationHeaderProps) { + const coords = useMemo( + () => coordFormatter.format(station.longitude, station.latitude).join(", "), + [station.latitude, station.longitude], + ); + + return ( +
+

{station.name}

+ + {[station.region, station.country].filter(Boolean).join(" · ")} + {" · "} + {coords} + +
+ ); +} diff --git a/packages/react/src/components/TideTable.stories.tsx b/packages/react/src/components/TideTable.stories.tsx new file mode 100644 index 00000000..66b21d0c --- /dev/null +++ b/packages/react/src/components/TideTable.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { NeapsProvider } from "../provider.js"; +import { TideTable } from "./TideTable.js"; + +const meta: Meta = { + title: "Components/TideTable", + component: TideTable, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const SingleDay: Story = { + args: { + id: "noaa/8443970", + days: 1, + }, +}; + +export const ThreeDays: Story = { + args: { + id: "noaa/8443970", + days: 3, + }, +}; + +export const SevenDays: Story = { + args: { + id: "noaa/8443970", + days: 7, + }, +}; + +export const NarrowWidth: Story = { + args: { + id: "noaa/8443970", + days: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Loading: Story = { + args: { + id: "noaa/8443970", + days: 1, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + id: "nonexistent/station", + days: 1, + }, +}; + +export const Empty: Story = { + args: { + extremes: [], + timezone: "America/New_York", + units: "meters", + }, +}; diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx new file mode 100644 index 00000000..9d607cbc --- /dev/null +++ b/packages/react/src/components/TideTable.tsx @@ -0,0 +1,180 @@ +import { useMemo } from "react"; + +import { useExtremes, type UseExtremesParams } from "../hooks/use-extremes.js"; +import { useNeapsConfig } from "../provider.js"; +import { formatLevel, formatTime, formatDate, getDateKey } from "../utils/format.js"; +import type { Extreme, Units } from "../types.js"; + +export interface TideTableDataProps { + extremes: Extreme[]; + timezone?: string; + units?: Units; +} + +export interface TideTableFetchProps { + id: string; + days?: number; + start?: Date; + end?: Date; + extremes?: undefined; +} + +export type TideTableProps = (TideTableDataProps | TideTableFetchProps) & { + className?: string; +}; + +function TideTableView({ + extremes, + timezone, + units, + locale, + className, +}: { + extremes: Extreme[]; + timezone: string; + units: Units; + locale: string; + className?: string; +}) { + const grouped = useMemo(() => { + const groups: Map = new Map(); + for (const extreme of extremes) { + const key = getDateKey(extreme.time, timezone); + if (!groups.has(key)) { + groups.set(key, { label: formatDate(extreme.time, timezone, locale), extremes: [] }); + } + groups.get(key)!.extremes.push(extreme); + } + return Array.from(groups.values()); + }, [extremes, timezone, locale]); + + const now = new Date(); + let foundNext = false; + + return ( +
+ + + + + + + + + + + {grouped.map((group) => + group.extremes.map((extreme, i) => { + const isNext = !foundNext && extreme.time > now; + if (isNext) foundNext = true; + + return ( + + {i === 0 ? ( + + ) : null} + + + + + ); + }), + )} + +
+ Date + + Time + + Level + + Type +
+ {group.label} + + + {formatTime(extreme.time, timezone, locale)} + + + + {formatLevel(extreme.level, units)} + + + + + {extreme.label} + +
+
+ ); +} + +export function TideTable(props: TideTableProps) { + const config = useNeapsConfig(); + + if (props.extremes) { + return ( + + ); + } + + return ; +} + +function TideTableFetcher({ + id, + days = 1, + start, + end, + className, +}: TideTableFetchProps & { className?: string }) { + const config = useNeapsConfig(); + + const effectiveStart = start ?? new Date(); + const effectiveEnd = end ?? new Date(effectiveStart.getTime() + days * 24 * 60 * 60 * 1000); + + const params: UseExtremesParams = { + id, + start: effectiveStart.toISOString(), + end: effectiveEnd.toISOString(), + }; + + const { data, isLoading, error } = useExtremes(params); + + if (isLoading) + return ( +
Loading tide data...
+ ); + if (error) return
{error.message}
; + + return ( + + ); +} diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts new file mode 100644 index 00000000..5a17e1df --- /dev/null +++ b/packages/react/src/components/index.ts @@ -0,0 +1,11 @@ +export * from "./TideStationHeader.js"; +export * from "./TideStation.js"; +export * from "./TideConditions.js"; +export * from "./TideCycleGraph.js"; +export * from "./TideGraph/index.js"; +export * from "./TideTable.js"; +export * from "./StationDisclaimers.js"; +export * from "./TideSettings.js"; +export * from "./StationSearch.js"; +export * from "./NearbyStations.js"; +export * from "./StationsMap.js"; diff --git a/packages/react/src/constants.ts b/packages/react/src/constants.ts new file mode 100644 index 00000000..159b67bd --- /dev/null +++ b/packages/react/src/constants.ts @@ -0,0 +1,2 @@ +/** Half of one tidal cycle ≈ 6 h 25 m (mean semidiurnal period / 2). */ +export const HALF_TIDE_CYCLE_MS = 6.417 * 60 * 60 * 1000; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 00000000..b1dd0ea6 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1,12 @@ +export * from "./use-container-width.js"; +export * from "./use-current-level.js"; +export * from "./use-dark-mode.js"; +export * from "./use-debounced-callback.js"; +export * from "./use-extremes.js"; +export * from "./use-nearby-stations.js"; +export * from "./use-station.js"; +export * from "./use-stations.js"; +export * from "./use-theme-colors.js"; +export * from "./use-tide-chunks.js"; +export * from "./use-tide-scales.js"; +export * from "./use-timeline.js"; diff --git a/packages/react/src/hooks/use-container-width.ts b/packages/react/src/hooks/use-container-width.ts new file mode 100644 index 00000000..5217aeb2 --- /dev/null +++ b/packages/react/src/hooks/use-container-width.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef, useState } from "react"; + +export function useContainerWidth() { + const ref = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setWidth(entry.contentRect.width); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return { ref, width }; +} diff --git a/packages/react/src/hooks/use-current-level.ts b/packages/react/src/hooks/use-current-level.ts new file mode 100644 index 00000000..65086cc1 --- /dev/null +++ b/packages/react/src/hooks/use-current-level.ts @@ -0,0 +1,45 @@ +import { useState, useEffect, useMemo } from "react"; +import type { TimelineEntry } from "../types.js"; + +export function interpolateLevel(timeline: TimelineEntry[], at: number): TimelineEntry | null { + if (!timeline.length) return null; + + let lo = -1; + let hi = -1; + for (let i = 0; i < timeline.length; i++) { + if (timeline[i].time.getTime() <= at) lo = i; + else if (hi === -1) { + hi = i; + break; + } + } + + if (lo === -1 || hi === -1) return null; + + const t0 = timeline[lo].time.getTime(); + const t1 = timeline[hi].time.getTime(); + const ratio = (at - t0) / (t1 - t0); + const level = timeline[lo].level + (timeline[hi].level - timeline[lo].level) * ratio; + + return { time: new Date(at), level }; +} + +/** + * Returns a TimelineEntry for the current moment by linearly interpolating + * between the two nearest entries in the timeline. Updates every minute. + * Returns null if the timeline is empty. + */ +export function useCurrentLevel(timeline: TimelineEntry[]): TimelineEntry | null { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const minute = 60_000; + const id = setInterval(() => { + const next = Math.floor(Date.now() / minute) * minute; + setNow((prev) => (prev !== next ? next : prev)); + }, 5_000); + return () => clearInterval(id); + }, []); + + return useMemo(() => interpolateLevel(timeline, now), [timeline, now]); +} diff --git a/packages/react/src/hooks/use-dark-mode.ts b/packages/react/src/hooks/use-dark-mode.ts new file mode 100644 index 00000000..da5e77a0 --- /dev/null +++ b/packages/react/src/hooks/use-dark-mode.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; + +/** + * Tracks dark mode state via `.dark` class on `` and `prefers-color-scheme` media query. + */ +export function useDarkMode(): boolean { + const [isDark, setIsDark] = useState(() => { + if (typeof document === "undefined") return false; + return ( + document.documentElement.classList.contains("dark") || + window.matchMedia("(prefers-color-scheme: dark)").matches + ); + }); + + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const update = () => { + setIsDark(document.documentElement.classList.contains("dark") || mq.matches); + }; + + // Watch for .dark class changes on + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + // Watch for system preference changes + mq.addEventListener("change", update); + + return () => { + observer.disconnect(); + mq.removeEventListener("change", update); + }; + }, []); + + return isDark; +} diff --git a/packages/react/src/hooks/use-debounced-callback.ts b/packages/react/src/hooks/use-debounced-callback.ts new file mode 100644 index 00000000..647191a0 --- /dev/null +++ b/packages/react/src/hooks/use-debounced-callback.ts @@ -0,0 +1,20 @@ +import { useCallback, useEffect, useRef } from "react"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useDebouncedCallback void>(fn: T, delay: number): T { + const timerRef = useRef>(undefined); + const fnRef = useRef(fn); + fnRef.current = fn; + + useEffect(() => { + return () => clearTimeout(timerRef.current); + }, []); + + return useCallback( + (...args: Parameters) => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => fnRef.current(...args), delay); + }, + [delay], + ) as T; +} diff --git a/packages/react/src/hooks/use-extremes.ts b/packages/react/src/hooks/use-extremes.ts new file mode 100644 index 00000000..93e323ad --- /dev/null +++ b/packages/react/src/hooks/use-extremes.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { + fetchExtremes, + fetchStationExtremes, + type LocationParams, + type StationPredictionParams, + type PredictionParams, +} from "../client.js"; +import { queryKeys } from "../query-keys.js"; + +export type UseExtremesParams = + | ({ id: string } & PredictionParams) + | (LocationParams & { id?: undefined }); + +export function useExtremes(params: UseExtremesParams) { + const { baseUrl, units, datum } = useNeapsConfig(); + const mergedUnits = params.units ?? units; + const mergedDatum = params.datum ?? datum; + + return useQuery({ + queryKey: queryKeys.extremes({ ...params, units: mergedUnits, datum: mergedDatum }), + queryFn: () => { + if (params.id) { + return fetchStationExtremes(baseUrl, { + ...params, + id: params.id, + units: mergedUnits, + datum: mergedDatum, + } as StationPredictionParams); + } + return fetchExtremes(baseUrl, { + ...(params as LocationParams), + units: mergedUnits, + datum: mergedDatum, + }); + }, + }); +} diff --git a/packages/react/src/hooks/use-nearby-stations.ts b/packages/react/src/hooks/use-nearby-stations.ts new file mode 100644 index 00000000..cec5850b --- /dev/null +++ b/packages/react/src/hooks/use-nearby-stations.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStations } from "../client.js"; +import { queryKeys } from "../query-keys.js"; + +export interface UseNearbyStationsParams { + latitude: number; + longitude: number; + maxResults?: number; + maxDistance?: number; +} + +export function useNearbyStations(params: UseNearbyStationsParams | undefined) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: queryKeys.nearbyStations(params ?? {}), + queryFn: () => fetchStations(baseUrl, params!), + enabled: !!params, + }); +} diff --git a/packages/react/src/hooks/use-station.ts b/packages/react/src/hooks/use-station.ts new file mode 100644 index 00000000..0dda8be1 --- /dev/null +++ b/packages/react/src/hooks/use-station.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStation } from "../client.js"; +import { queryKeys } from "../query-keys.js"; + +export function useStation(id: string | undefined) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: queryKeys.station(id), + queryFn: () => fetchStation(baseUrl, id!), + enabled: !!id, + }); +} diff --git a/packages/react/src/hooks/use-stations.ts b/packages/react/src/hooks/use-stations.ts new file mode 100644 index 00000000..a01e6169 --- /dev/null +++ b/packages/react/src/hooks/use-stations.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStations, type StationsSearchParams } from "../client.js"; +import type { StationSummary } from "../types.js"; +import { queryKeys } from "../query-keys.js"; + +type StationsQueryOptions = Pick, "placeholderData" | "enabled">; + +export function useStations(params: StationsSearchParams = {}, options: StationsQueryOptions = {}) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: queryKeys.stations(params), + queryFn: () => fetchStations(baseUrl, params), + ...options, + }); +} diff --git a/packages/react/src/hooks/use-theme-colors.ts b/packages/react/src/hooks/use-theme-colors.ts new file mode 100644 index 00000000..b078577a --- /dev/null +++ b/packages/react/src/hooks/use-theme-colors.ts @@ -0,0 +1,96 @@ +import { useMemo } from "react"; +import { formatHex } from "culori"; +import { useDarkMode } from "./use-dark-mode.js"; + +export interface ThemeColors { + primary: string; + secondary: string; + high: string; + low: string; + danger: string; + bg: string; + bgSubtle: string; + text: string; + textMuted: string; + border: string; + mapText: string; + mapBg: string; +} + +const FALLBACKS: ThemeColors = { + primary: "#0284c7", + secondary: "#7c3aed", + high: "#0d9488", + low: "#d97706", + danger: "#ef4444", + bg: "#ffffff", + bgSubtle: "#f8fafc", + text: "#0f172a", + textMuted: "#64748b", + border: "#e2e8f0", + mapText: "#0f172a", + mapBg: "#ffffff", +}; + +/** + * Resolve a CSS custom property to a hex color that any consumer + * (MapLibre GL, canvas, etc.) can understand. Converts any CSS color + * format (oklch, lab, hsl, etc.) to #rrggbb via culori. + */ +function readCSSVar(name: string, fallback: string): string { + if (typeof document === "undefined") return fallback; + const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (!raw) return fallback; + return formatHex(raw) ?? fallback; +} + +/** Add alpha transparency to a color string (hex or rgb), returning rgba(). */ +export function withAlpha(color: string, alpha: number): string { + // Handle rgb(r, g, b) or rgb(r g b) + const rgbMatch = color.match(/^rgb\((\d+)[, ]+(\d+)[, ]+(\d+)\)$/); + if (rgbMatch) { + return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`; + } + // Handle hex + if (color.startsWith("#")) { + const hex = + color.length === 4 + ? `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}` + : color.slice(0, 7); + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; + } + return color; +} + +/** + * Reads resolved `--neaps-*` CSS custom property values from the DOM. + * Re-computes when dark mode toggles. + * + * `--neaps-map-text` and `--neaps-map-bg` default to `--neaps-text` and `--neaps-bg` + * respectively, so consumers only need to set them when the map background differs + * from the app theme (e.g. satellite imagery). + */ +export function useThemeColors(): ThemeColors { + const isDark = useDarkMode(); + return useMemo(() => { + const text = readCSSVar("--neaps-text", FALLBACKS.text); + const bg = readCSSVar("--neaps-bg", FALLBACKS.bg); + return { + primary: readCSSVar("--neaps-primary", FALLBACKS.primary), + secondary: readCSSVar("--neaps-secondary", FALLBACKS.secondary), + high: readCSSVar("--neaps-high", FALLBACKS.high), + low: readCSSVar("--neaps-low", FALLBACKS.low), + danger: readCSSVar("--neaps-danger", FALLBACKS.danger), + bg, + bgSubtle: readCSSVar("--neaps-bg-subtle", FALLBACKS.bgSubtle), + text, + textMuted: readCSSVar("--neaps-text-muted", FALLBACKS.textMuted), + border: readCSSVar("--neaps-border", FALLBACKS.border), + mapText: readCSSVar("--neaps-map-text", text), + mapBg: readCSSVar("--neaps-map-bg", bg), + }; + }, [isDark]); +} diff --git a/packages/react/src/hooks/use-tide-chunks.ts b/packages/react/src/hooks/use-tide-chunks.ts new file mode 100644 index 00000000..14c75709 --- /dev/null +++ b/packages/react/src/hooks/use-tide-chunks.ts @@ -0,0 +1,182 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStationTimeline, fetchStationExtremes } from "../client.js"; +import type { TimelineEntry, Extreme, Station, Units } from "../types.js"; +import { queryKeys } from "../query-keys.js"; + +const CHUNK_DAYS = 7; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +interface ChunkRange { + start: string; // ISO + end: string; // ISO +} + +function getChunkRange(anchorMs: number, offset: number): ChunkRange { + const start = new Date(anchorMs + offset * CHUNK_DAYS * MS_PER_DAY); + start.setHours(0, 0, 0, 0); + const end = new Date(start.getTime() + CHUNK_DAYS * MS_PER_DAY); + return { start: start.toISOString(), end: end.toISOString() }; +} + +function getInitialChunks(): ChunkRange[] { + const now = new Date(); + now.setHours(0, 0, 0, 0); + const anchorMs = now.getTime(); + return [getChunkRange(anchorMs, -1), getChunkRange(anchorMs, 0), getChunkRange(anchorMs, 1)]; +} + +export interface UseTideChunksParams { + id: string; +} + +export interface UseTideChunksReturn { + timeline: TimelineEntry[]; + extremes: Extreme[]; + dataStart: number; + dataEnd: number; + yDomain: [number, number]; + loadPrevious: () => void; + loadNext: () => void; + isLoadingPrevious: boolean; + isLoadingNext: boolean; + isLoading: boolean; + error: Error | null; + station: Station | null; + timezone: string; + units: Units; + datum: string | undefined; +} + +export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn { + const { baseUrl, units, datum, timezone } = useNeapsConfig(); + const [chunks, setChunks] = useState(getInitialChunks); + const yDomainRef = useRef<[number, number] | null>(null); + + const timelineQueries = useQueries({ + queries: chunks.map((chunk) => ({ + queryKey: queryKeys.timeline({ id, start: chunk.start, end: chunk.end, units, datum }), + queryFn: () => + fetchStationTimeline(baseUrl, { id, start: chunk.start, end: chunk.end, units, datum }), + staleTime: 5 * 60 * 1000, + })), + }); + + const extremesQueries = useQueries({ + queries: chunks.map((chunk) => ({ + queryKey: queryKeys.extremes({ id, start: chunk.start, end: chunk.end, units, datum }), + queryFn: () => + fetchStationExtremes(baseUrl, { id, start: chunk.start, end: chunk.end, units, datum }), + staleTime: 5 * 60 * 1000, + })), + }); + + const timeline = useMemo(() => { + const seen = new Set(); + const result: TimelineEntry[] = []; + for (const q of timelineQueries) { + if (!q.data) continue; + for (const entry of q.data.timeline) { + const ms = entry.time.getTime(); + if (!seen.has(ms)) { + seen.add(ms); + result.push(entry); + } + } + } + result.sort((a, b) => a.time.getTime() - b.time.getTime()); + return result; + }, [timelineQueries]); + + const extremes = useMemo(() => { + const seen = new Set(); + const result: Extreme[] = []; + for (const q of extremesQueries) { + if (!q.data) continue; + for (const entry of q.data.extremes) { + const ms = entry.time.getTime(); + if (!seen.has(ms)) { + seen.add(ms); + result.push(entry); + } + } + } + result.sort((a, b) => a.time.getTime() - b.time.getTime()); + return result; + }, [extremesQueries]); + + // Expanding-only y-domain + const yDomain = useMemo<[number, number]>(() => { + const levels = [...timeline.map((d) => d.level), ...extremes.map((e) => e.level)]; + if (!levels.length) return yDomainRef.current ?? [0, 1]; + + const dataMin = Math.min(0, ...levels); + const dataMax = Math.max(...levels); + const pad = (dataMax - dataMin) * 0.2 || 0.5; + + const prev = yDomainRef.current; + const newDomain: [number, number] = [ + prev ? Math.min(prev[0], dataMin - pad) : dataMin - pad, + prev ? Math.max(prev[1], dataMax + pad) : dataMax + pad, + ]; + yDomainRef.current = newDomain; + return newDomain; + }, [timeline, extremes]); + + const dataStart = useMemo(() => new Date(chunks[0].start).getTime(), [chunks]); + const dataEnd = useMemo(() => new Date(chunks[chunks.length - 1].end).getTime(), [chunks]); + + const loadPrevious = useCallback(() => { + setChunks((prev) => { + const earliestStart = new Date(prev[0].start).getTime(); + const newChunk = getChunkRange(earliestStart, -1); + return [newChunk, ...prev]; + }); + }, []); + + const loadNext = useCallback(() => { + setChunks((prev) => { + const latestEnd = new Date(prev[prev.length - 1].end).getTime(); + const newChunk: ChunkRange = { + start: new Date(latestEnd).toISOString(), + end: new Date(latestEnd + CHUNK_DAYS * MS_PER_DAY).toISOString(), + }; + return [...prev, newChunk]; + }); + }, []); + + const firstTimeline = timelineQueries.find((q) => q.data); + const firstExtremes = extremesQueries.find((q) => q.data); + const station = firstTimeline?.data?.station ?? firstExtremes?.data?.station ?? null; + + const isLoading = + timelineQueries.some((q) => q.isLoading) || extremesQueries.some((q) => q.isLoading); + const isLoadingPrevious = timelineQueries[0]?.isLoading || extremesQueries[0]?.isLoading; + const isLoadingNext = + timelineQueries[timelineQueries.length - 1]?.isLoading || + extremesQueries[extremesQueries.length - 1]?.isLoading; + + const error = + timelineQueries.find((q) => q.error)?.error ?? + extremesQueries.find((q) => q.error)?.error ?? + null; + + return { + timeline, + extremes, + dataStart, + dataEnd, + yDomain, + loadPrevious, + loadNext, + isLoadingPrevious, + isLoadingNext, + isLoading, + error: error as Error | null, + station, + timezone: timezone ?? station?.timezone ?? "UTC", + units: firstTimeline?.data?.units ?? units, + datum: firstTimeline?.data?.datum ?? firstExtremes?.data?.datum, + }; +} diff --git a/packages/react/src/hooks/use-tide-scales.ts b/packages/react/src/hooks/use-tide-scales.ts new file mode 100644 index 00000000..0ff67bc3 --- /dev/null +++ b/packages/react/src/hooks/use-tide-scales.ts @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { scaleTime, scaleLinear } from "@visx/scale"; +import type { TimelineEntry, Extreme } from "../types.js"; + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +export function useTideScales({ + timeline, + extremes, + width, + height, + margin, + domainOverride, + yDomainOverride, +}: { + timeline: TimelineEntry[]; + extremes?: Extreme[]; + width: number; + height: number; + margin: Margin; + domainOverride?: { xMin: number; xMax: number }; + yDomainOverride?: [number, number]; +}) { + return useMemo(() => { + const innerW = Math.max(0, width - margin.left - margin.right); + const innerH = Math.max(0, height - margin.top - margin.bottom); + + const times = timeline.map((d) => d.time.getTime()); + const xMin = domainOverride?.xMin ?? (times.length ? Math.min(...times) : 0); + const xMax = domainOverride?.xMax ?? (times.length ? Math.max(...times) : 1); + + const xScale = scaleTime({ + domain: [xMin, xMax], + range: [0, innerW], + }); + + let yDomain: [number, number]; + if (yDomainOverride) { + yDomain = yDomainOverride; + } else { + const levels = [...timeline.map((d) => d.level), ...(extremes?.map((e) => e.level) ?? [])]; + const yMin = levels.length ? Math.min(0, ...levels) : 0; + const yMax = levels.length ? Math.max(...levels) : 1; + const yPad = (yMax - yMin) * 0.2 || 0.5; + yDomain = [yMin - yPad, yMax + yPad]; + } + + const yScale = scaleLinear({ + domain: yDomain, + range: [innerH, 0], + nice: true, + }); + + return { xScale, yScale, innerW, innerH }; + }, [timeline, extremes, width, height, margin, domainOverride, yDomainOverride]); +} + +export type TideScales = ReturnType; +export type TideXScale = TideScales["xScale"]; +export type TideYScale = TideScales["yScale"]; diff --git a/packages/react/src/hooks/use-timeline.ts b/packages/react/src/hooks/use-timeline.ts new file mode 100644 index 00000000..980cac22 --- /dev/null +++ b/packages/react/src/hooks/use-timeline.ts @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { + fetchTimeline, + fetchStationTimeline, + type LocationParams, + type StationPredictionParams, + type PredictionParams, +} from "../client.js"; +import { queryKeys } from "../query-keys.js"; + +export type UseTimelineParams = + | ({ id: string } & PredictionParams) + | (LocationParams & { id?: undefined }); + +export function useTimeline(params: UseTimelineParams) { + const { baseUrl, units, datum } = useNeapsConfig(); + const mergedUnits = params.units ?? units; + const mergedDatum = params.datum ?? datum; + + return useQuery({ + queryKey: queryKeys.timeline({ ...params, units: mergedUnits, datum: mergedDatum }), + queryFn: () => { + if (params.id) { + return fetchStationTimeline(baseUrl, { + ...params, + id: params.id, + units: mergedUnits, + datum: mergedDatum, + } as StationPredictionParams); + } + return fetchTimeline(baseUrl, { + ...(params as LocationParams), + units: mergedUnits, + datum: mergedDatum, + }); + }, + }); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts new file mode 100644 index 00000000..0ac49595 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,14 @@ +export * from "./types.js"; +export * from "./provider.js"; +export * from "./client.js"; +export * from "./hooks/index.js"; +export * from "./components/index.js"; +export * from "./query-keys.js"; +export * from "./prefetch.js"; + +// Utilities +export { formatLevel, formatTime, formatDate, formatDistance } from "./utils/format.js"; +export { getDefaultUnits, getDefaultRange } from "./utils/defaults.js"; + +// Re-export hydration utilities from @tanstack/react-query +export { dehydrate, HydrationBoundary } from "@tanstack/react-query"; diff --git a/packages/react/src/prefetch.ts b/packages/react/src/prefetch.ts new file mode 100644 index 00000000..6ad9bc1d --- /dev/null +++ b/packages/react/src/prefetch.ts @@ -0,0 +1,74 @@ +import { type QueryClient } from "@tanstack/react-query"; + +import type { Units } from "./types.js"; +import { queryKeys } from "./query-keys.js"; +import { + fetchStation, + fetchStationTimeline, + fetchStationExtremes, + fetchStations, +} from "./client.js"; +import { getDefaultRange, getDefaultUnits } from "./utils/defaults.js"; + +export interface PrefetchTideStationOptions { + units?: Units; + locale?: string; + datum?: string; +} + +/** + * Prefetch all queries that the `` component will make. + * Call this on the server, then use `dehydrate(queryClient)` to pass the + * cache to the client via a pre-hydrated QueryClient. + * + * Pass either `units` directly or `locale` to derive units automatically. + */ +export async function prefetchTideStation( + queryClient: QueryClient, + baseUrl: string, + id: string, + { units, locale, datum }: PrefetchTideStationOptions = {}, +): Promise { + const resolvedUnits = units ?? getDefaultUnits(locale); + const range = getDefaultRange(); + const params = { id, start: range.start, end: range.end, units: resolvedUnits, datum }; + + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: queryKeys.station(id), + queryFn: () => fetchStation(baseUrl, id), + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.timeline(params), + queryFn: () => fetchStationTimeline(baseUrl, params), + }), + queryClient.prefetchQuery({ + queryKey: queryKeys.extremes(params), + queryFn: () => fetchStationExtremes(baseUrl, params), + }), + ]); +} + +export interface PrefetchNearbyStationsOptions { + latitude: number; + longitude: number; + maxResults?: number; +} + +/** + * Prefetch the nearby-stations query that `` will make. + * The default maxResults matches the component's internal default (5 + 1 for the + * excluded current station). + */ +export async function prefetchNearbyStations( + queryClient: QueryClient, + baseUrl: string, + { latitude, longitude, maxResults = 6 }: PrefetchNearbyStationsOptions, +): Promise { + const params = { latitude, longitude, maxResults }; + + await queryClient.prefetchQuery({ + queryKey: queryKeys.nearbyStations(params), + queryFn: () => fetchStations(baseUrl, params), + }); +} diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx new file mode 100644 index 00000000..663d3a68 --- /dev/null +++ b/packages/react/src/provider.tsx @@ -0,0 +1,156 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { Units } from "./types.js"; +import { getDefaultUnits } from "./utils/defaults.js"; + +const defaultLocale = typeof navigator !== "undefined" ? navigator.language : "en-US"; + +export interface NeapsConfig { + baseUrl: string; + units: Units; + datum?: string; + timezone?: string; + locale: string; +} + +export type NeapsConfigUpdater = ( + patch: Partial>, +) => void; + +interface NeapsContextValue { + config: NeapsConfig; + updateConfig: NeapsConfigUpdater; +} + +const NeapsContext = createContext(null); + +const SETTINGS_KEY = "neaps-settings"; +type PersistedSettings = Partial>; + +function loadSettings(): PersistedSettings { + try { + const raw = typeof localStorage !== "undefined" ? localStorage.getItem(SETTINGS_KEY) : null; + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function saveSettings(settings: PersistedSettings): void { + try { + if (typeof localStorage !== "undefined") { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } + } catch { + // Ignore localStorage errors (quota, SSR, etc.) + } +} + +/** Create a new QueryClient with the standard neaps defaults. */ +export function createQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }); +} + +let defaultQueryClient: QueryClient | null = null; + +function getDefaultQueryClient(): QueryClient { + // On the server, always return a fresh client to prevent cross-request data leakage. + if (typeof window === "undefined") return createQueryClient(); + if (!defaultQueryClient) defaultQueryClient = createQueryClient(); + return defaultQueryClient; +} + +export interface NeapsProviderProps { + baseUrl: string; + units?: Units; + datum?: string; + timezone?: string; + locale?: string; + queryClient?: QueryClient; + children: ReactNode; +} + +export function NeapsProvider({ + baseUrl, + locale: initialLocale = defaultLocale, + units: initialUnits = getDefaultUnits(initialLocale), + datum: initialDatum, + timezone: initialTimezone, + queryClient, + children, +}: NeapsProviderProps) { + // Start with empty overrides to match the server render. + // localStorage is read in useEffect to avoid hydration mismatches. + const [overrides, setOverrides] = useState({}); + + useEffect(() => { + const saved = loadSettings(); + if (Object.keys(saved).length > 0) { + setOverrides(saved); + } + }, []); + + const config = useMemo( + () => ({ + baseUrl, + units: overrides.units ?? initialUnits, + datum: overrides.datum ?? initialDatum, + timezone: overrides.timezone ?? initialTimezone, + locale: overrides.locale ?? initialLocale, + }), + [baseUrl, initialUnits, initialDatum, initialTimezone, initialLocale, overrides], + ); + + const updateConfig = useCallback((patch) => { + setOverrides((prev) => { + const next = { ...prev, ...patch }; + saveSettings(next); + return next; + }); + }, []); + + const contextValue = useMemo( + () => ({ config, updateConfig }), + [config, updateConfig], + ); + + return ( + + + {children} + + + ); +} + +export function useNeapsConfig(): NeapsConfig { + const ctx = useContext(NeapsContext); + if (!ctx) { + throw new Error("useNeapsConfig must be used within a "); + } + return ctx.config; +} + +export function useUpdateConfig(): NeapsConfigUpdater { + const ctx = useContext(NeapsContext); + if (!ctx) { + throw new Error("useUpdateConfig must be used within a "); + } + return ctx.updateConfig; +} diff --git a/packages/react/src/query-keys.ts b/packages/react/src/query-keys.ts new file mode 100644 index 00000000..07771155 --- /dev/null +++ b/packages/react/src/query-keys.ts @@ -0,0 +1,7 @@ +export const queryKeys = { + station: (id: string | undefined) => ["neaps", "station", id] as const, + stations: (params: object) => ["neaps", "stations", params] as const, + nearbyStations: (params: object) => ["neaps", "nearby-stations", params] as const, + extremes: (params: object) => ["neaps", "extremes", params] as const, + timeline: (params: object) => ["neaps", "timeline", params] as const, +}; diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css new file mode 100644 index 00000000..45121f3a --- /dev/null +++ b/packages/react/src/styles.css @@ -0,0 +1,66 @@ +/* Neaps React Component Library — Theme Variables + * + * References Tailwind v4 CSS variables with hex fallbacks + * for consumers not using Tailwind. Override any --neaps-* + * variable to customize the theme. + */ + +:root { + color-scheme: light dark; + --neaps-primary: light-dark(var(--color-sky-600, #0284c7), var(--color-sky-400, #38bdf8)); + --neaps-secondary: light-dark(var(--color-violet-600, #7c3aed), var(--color-violet-400, #a78bfa)); + --neaps-high: light-dark(var(--color-teal-600, #0d9488), var(--color-teal-400, #2dd4bf)); + --neaps-low: light-dark(var(--color-amber-600, #d97706), var(--color-amber-400, #fbbf24)); + --neaps-danger: light-dark(var(--color-red-500, #ef4444), var(--color-red-400, #f87171)); + --neaps-bg: light-dark(var(--color-white, #ffffff), var(--color-slate-900, #0f172a)); + --neaps-bg-subtle: light-dark(var(--color-slate-50, #f8fafc), var(--color-slate-800, #1e293b)); + --neaps-text: light-dark(var(--color-slate-900, #0f172a), var(--color-slate-100, #f1f5f9)); + --neaps-text-muted: light-dark(var(--color-slate-500, #64748b), var(--color-slate-400, #94a3b8)); + --neaps-border: light-dark(var(--color-slate-200, #e2e8f0), var(--color-slate-700, #334155)); + --neaps-night: light-dark( + color-mix(in srgb, var(--color-slate-100, #eef2f6) 50%, transparent), + color-mix(in srgb, var(--color-slate-950, #020617) 50%, transparent) + ); +} + +/* Hide scrollbar while keeping scroll functional */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Custom select — appearance-none strips native chrome */ +.neaps-select { + appearance: none; + padding: 0.375rem 1.75rem 0.375rem 0.75rem; + font-size: 0.75rem; + line-height: 1rem; + font-weight: 500; + border-radius: 0.375rem; + border: 1px solid var(--neaps-border); + background-color: var(--neaps-bg); + color: var(--neaps-text); + outline: none; + cursor: pointer; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23666'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 4px center; + background-size: 16px 16px; +} +.neaps-select:focus { + border-color: var(--neaps-primary); +} + +/* Map marker — used by MapLibre GL, not styleable via Tailwind */ +.neaps-map-marker { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--neaps-primary); + border: 2px solid #ffffff; + box-shadow: 0 1px 3px rgb(0 0 0 / 0.3); + cursor: pointer; +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 00000000..8dc17ccd --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,77 @@ +export type Units = "meters" | "feet"; + +export interface StationSummary { + id: string; + name: string; + latitude: number; + longitude: number; + region?: string; + country: string; + continent: string; + timezone: string; + type: "reference" | "subordinate"; + distance?: number; +} + +export interface Station extends StationSummary { + source: { + id: string; + name: string; + url: string; + }; + license: { + type: string; + commercial_use: boolean; + url?: string; + notes?: string; + }; + disclaimers?: string; + datums: Record; + defaultDatum?: string; + harmonic_constituents: { + name: string; + amplitude: number; + phase: number; + }[]; + offsets?: { + reference: string; + height?: { + high: number; + low: number; + type: "ratio" | "fixed"; + }; + time?: { + high: number; + low: number; + }; + }; +} + +export interface Extreme { + time: Date; + level: number; + high: boolean; + low: boolean; + label: string; +} + +export interface TimelineEntry { + time: Date; + level: number; +} + +export interface ExtremesResponse { + datum: string; + units: Units; + station: Station; + distance: number; + extremes: Extreme[]; +} + +export interface TimelineResponse { + datum: string; + units: Units; + station: Station; + distance: number; + timeline: TimelineEntry[]; +} diff --git a/packages/react/src/utils/defaults.ts b/packages/react/src/utils/defaults.ts new file mode 100644 index 00000000..b9c5c8ab --- /dev/null +++ b/packages/react/src/utils/defaults.ts @@ -0,0 +1,21 @@ +import type { Units } from "../types.js"; + +const IMPERIAL_LOCALES = ["en-US", "en-LR", "my-MM"]; + +/** + * Determine the default unit system from a locale string. + * Falls back to detecting from `navigator.language` when no locale is provided. + */ +export function getDefaultUnits(locale?: string): Units { + const lang = locale ?? (typeof navigator !== "undefined" ? navigator.language : "en-US"); + return IMPERIAL_LOCALES.includes(lang) ? "feet" : "meters"; +} + +/** Compute the default date range used by TideStation: start of today (UTC) through +7 days. */ +export function getDefaultRange(): { start: string; end: string } { + const now = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const end = new Date(start.getTime()); + end.setUTCDate(end.getUTCDate() + 7); + return { start: start.toISOString(), end: end.toISOString() }; +} diff --git a/packages/react/src/utils/format.ts b/packages/react/src/utils/format.ts new file mode 100644 index 00000000..71d6a17d --- /dev/null +++ b/packages/react/src/utils/format.ts @@ -0,0 +1,44 @@ +import type { Units } from "../types.js"; + +/** Format a water level value with unit suffix. */ +export function formatLevel(level: number, units: Units): string { + const precision = units === "feet" ? 1 : 2; + const suffix = units === "feet" ? "ft" : "m"; + return `${level.toFixed(precision)} ${suffix}`; +} + +/** Format a time in the station's timezone. */ +export function formatTime(time: Date, timezone: string, locale?: string): string { + return time.toLocaleTimeString(locale, { + timeZone: timezone, + hour: "numeric", + minute: "2-digit", + }); +} + +/** Format a date in the station's timezone. */ +export function formatDate(time: Date, timezone: string, locale?: string): string { + return time.toLocaleDateString(locale, { + timeZone: timezone, + weekday: "short", + month: "short", + day: "numeric", + }); +} + +/** Format a distance in meters as a human-readable string. */ +export function formatDistance(meters: number, units: Units): string { + if (units === "feet") { + const miles = meters / 1609.344; + if (miles < 0.1) return `${Math.round(meters * 3.2808399)} ft`; + return miles >= 10 ? `${Math.round(miles)} mi` : `${miles.toFixed(1)} mi`; + } + if (meters < 1000) return `${Math.round(meters)} m`; + const km = meters / 1000; + return km >= 10 ? `${Math.round(km)} km` : `${km.toFixed(1)} km`; +} + +/** Get a date key (YYYY-MM-DD) in the station's timezone. */ +export function getDateKey(time: Date, timezone: string): string { + return time.toLocaleDateString("en-CA", { timeZone: timezone }); +} diff --git a/packages/react/src/utils/sun.ts b/packages/react/src/utils/sun.ts new file mode 100644 index 00000000..10547c2d --- /dev/null +++ b/packages/react/src/utils/sun.ts @@ -0,0 +1,99 @@ +import { Body, Observer, SearchRiseSet } from "astronomy-engine"; + +export interface NightInterval { + start: number; // ms timestamp (sunset) + end: number; // ms timestamp (sunrise) +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Returns the midpoint between sunrise and sunset for each day in the range. + * Falls back to noon (UTC) if sun times can't be computed (e.g. polar regions). + */ +export function getDaylightMidpoints( + latitude: number, + longitude: number, + startMs: number, + endMs: number, +): Date[] { + const observer = new Observer(latitude, longitude, 0); + const midpoints: Date[] = []; + + const cursor = new Date(startMs); + cursor.setUTCHours(0, 0, 0, 0); + + while (cursor.getTime() <= endMs) { + const sunrise = SearchRiseSet(Body.Sun, observer, +1, cursor, 2); + const sunset = SearchRiseSet(Body.Sun, observer, -1, cursor, 2); + + if (sunrise && sunset) { + const sunriseMs = sunrise.date.getTime(); + const sunsetMs = sunset.date.getTime(); + + // Ensure we have the sunrise/sunset pair for the same day + if (sunsetMs > sunriseMs) { + const mid = new Date((sunriseMs + sunsetMs) / 2); + if (mid.getTime() >= startMs && mid.getTime() <= endMs) { + midpoints.push(mid); + } + } + } + + cursor.setTime(cursor.getTime() + MS_PER_DAY); + } + + return midpoints; +} + +/** + * Returns night intervals (sunset → sunrise) for a given location and time range. + * Pads by 1 day on each side to capture partial nights at boundaries. + */ +export function getNightIntervals( + latitude: number, + longitude: number, + startMs: number, + endMs: number, +): NightInterval[] { + const observer = new Observer(latitude, longitude, 0); + const intervals: NightInterval[] = []; + + // Start 1 day before to catch a sunset that happened before our range + const cursor = new Date(startMs - MS_PER_DAY); + cursor.setHours(12, 0, 0, 0); // noon local-ish to avoid ambiguity + + const limit = endMs + MS_PER_DAY; + + while (cursor.getTime() < limit) { + const sunset = SearchRiseSet(Body.Sun, observer, -1, cursor, 2); + if (!sunset) { + // Polar region — no sunset; skip this day + cursor.setTime(cursor.getTime() + MS_PER_DAY); + continue; + } + + const sunrise = SearchRiseSet(Body.Sun, observer, +1, sunset.date, 2); + if (!sunrise) { + // Polar region — no sunrise after sunset; skip + cursor.setTime(cursor.getTime() + MS_PER_DAY); + continue; + } + + const sunsetMs = sunset.date.getTime(); + const sunriseMs = sunrise.date.getTime(); + + // Only include intervals that overlap our range + if (sunriseMs > startMs && sunsetMs < endMs) { + intervals.push({ + start: Math.max(sunsetMs, startMs), + end: Math.min(sunriseMs, endMs), + }); + } + + // Advance past this sunrise to find the next sunset + cursor.setTime(sunriseMs + 60 * 60 * 1000); // +1h past sunrise + } + + return intervals; +} diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx new file mode 100644 index 00000000..80fcf278 --- /dev/null +++ b/packages/react/test/a11y.test.tsx @@ -0,0 +1,102 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import axe from "axe-core"; +import { TideTable } from "../src/components/TideTable.js"; +import { StationSearch } from "../src/components/StationSearch.js"; +import { NearbyStations } from "../src/components/NearbyStations.js"; +import { TideStation } from "../src/components/TideStation.js"; +import { TideGraphChart } from "../src/components/TideGraph/index.js"; +import { createTestWrapper } from "./helpers.js"; + +async function checkA11y(container: HTMLElement) { + const results = await axe.run(container, { + rules: { "color-contrast": { enabled: false } }, + }); + if (results.violations.length > 0) { + const message = results.violations + .map((v) => `${v.id}: ${v.description} (${v.nodes.length} nodes)`) + .join("\n"); + throw new Error(`Accessibility violations:\n${message}`); + } +} + +const STATION_ID = "noaa/8443970"; + +describe("accessibility", () => { + test("TideTable with data has no violations", async () => { + const extremes = [ + { time: new Date("2025-12-17T04:30:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T10:45:00Z"), level: 0.2, high: false, low: true, label: "Low" }, + { time: new Date("2025-12-17T16:00:00Z"), level: 1.4, high: true, low: false, label: "High" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + await checkA11y(container); + }); + + test("StationSearch has no violations", async () => { + const { container } = render( {}} />, { + wrapper: createTestWrapper(), + }); + + await checkA11y(container); + }); + + test("NearbyStations has no violations after loading", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + await checkA11y(container); + }); + + test("TideStation has no violations after loading", { timeout: 15000 }, async () => { + const { container } = render(, { wrapper: createTestWrapper() }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + await checkA11y(container); + }); + + test("TideGraphChart with data has no violations", async () => { + const timeline = [ + { time: new Date("2025-12-17T00:00:00Z"), level: 0.5 }, + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5 }, + { time: new Date("2025-12-17T12:00:00Z"), level: 0.3 }, + { time: new Date("2025-12-17T18:00:00Z"), level: 1.4 }, + ]; + + const { container } = render( + {}} + />, + { + wrapper: createTestWrapper(), + }, + ); + + await checkA11y(container); + }); +}); diff --git a/packages/react/test/client.test.ts b/packages/react/test/client.test.ts new file mode 100644 index 00000000..8a6f01e8 --- /dev/null +++ b/packages/react/test/client.test.ts @@ -0,0 +1,167 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { + fetchStation, + fetchStations, + fetchExtremes, + fetchTimeline, + fetchStationExtremes, + fetchStationTimeline, +} from "../src/client.js"; + +const BASE_URL = "https://api.example.com"; +const BASE_URL_WITH_PATH = "https://api.example.com/some/deep/path"; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +function mockFetch(data: unknown, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + }), + ); +} + +describe("base URL with path", () => { + test("preserves base URL path for station requests", async () => { + mockFetch({ id: "noaa/8722588", name: "Test Station" }); + + await fetchStation(BASE_URL_WITH_PATH, "noaa/8722588"); + + expect(fetch).toHaveBeenCalledWith( + "https://api.example.com/some/deep/path/tides/stations/noaa/8722588", + ); + }); + + test("preserves base URL path for extremes requests", async () => { + mockFetch({ extremes: [] }); + + await fetchExtremes(BASE_URL_WITH_PATH, { latitude: 26.7, longitude: -80.05 }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + expect(new URL(url).pathname).toBe("/some/deep/path/tides/extremes"); + }); + + test("preserves base URL path with trailing slash", async () => { + mockFetch({ timeline: [] }); + + await fetchTimeline(BASE_URL_WITH_PATH + "/", { latitude: 26.7, longitude: -80.05 }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + expect(new URL(url).pathname).toBe("/some/deep/path/tides/timeline"); + }); +}); + +describe("fetchStation", () => { + test("builds correct URL from composite id", async () => { + mockFetch({ id: "noaa/8722588", name: "Test Station" }); + + await fetchStation(BASE_URL, "noaa/8722588"); + + expect(fetch).toHaveBeenCalledWith("https://api.example.com/tides/stations/noaa/8722588"); + }); + + test("throws on invalid id format", () => { + expect(() => fetchStation(BASE_URL, "invalid")).toThrow('Invalid station ID: "invalid"'); + }); + + test("throws on HTTP error with message from body", async () => { + mockFetch({ message: "Station not found" }, 404); + + await expect(fetchStation(BASE_URL, "noaa/999999")).rejects.toThrow("Station not found"); + }); +}); + +describe("fetchStations", () => { + test("search by query", async () => { + mockFetch([]); + + await fetchStations(BASE_URL, { query: "palm beach" }); + + expect(fetch).toHaveBeenCalledWith("https://api.example.com/tides/stations?query=palm+beach"); + }); + + test("proximity search", async () => { + mockFetch([]); + + await fetchStations(BASE_URL, { latitude: 26.7, longitude: -80.05, maxResults: 5 }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + const parsed = new URL(url); + expect(parsed.searchParams.get("latitude")).toBe("26.7"); + expect(parsed.searchParams.get("longitude")).toBe("-80.05"); + expect(parsed.searchParams.get("maxResults")).toBe("5"); + }); + + test("omits undefined params", async () => { + mockFetch([]); + + await fetchStations(BASE_URL, {}); + + expect(fetch).toHaveBeenCalledWith("https://api.example.com/tides/stations"); + }); +}); + +describe("fetchExtremes", () => { + test("includes all params in URL", async () => { + mockFetch({ extremes: [] }); + + await fetchExtremes(BASE_URL, { + latitude: 26.7, + longitude: -80.05, + start: "2025-01-01T00:00:00Z", + end: "2025-01-02T00:00:00Z", + datum: "MLLW", + units: "feet", + }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/tides/extremes"); + expect(parsed.searchParams.get("latitude")).toBe("26.7"); + expect(parsed.searchParams.get("units")).toBe("feet"); + }); +}); + +describe("fetchTimeline", () => { + test("builds correct URL", async () => { + mockFetch({ timeline: [] }); + + await fetchTimeline(BASE_URL, { latitude: 26.7, longitude: -80.05 }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + expect(new URL(url).pathname).toBe("/tides/timeline"); + }); +}); + +describe("fetchStationExtremes", () => { + test("uses station path and strips id from query params", async () => { + mockFetch({ extremes: [] }); + + await fetchStationExtremes(BASE_URL, { + id: "noaa/8722588", + datum: "MSL", + }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + const parsed = new URL(url); + expect(parsed.pathname).toBe("/tides/stations/noaa/8722588/extremes"); + expect(parsed.searchParams.get("datum")).toBe("MSL"); + expect(parsed.searchParams.has("id")).toBe(false); + }); +}); + +describe("fetchStationTimeline", () => { + test("uses station path", async () => { + mockFetch({ timeline: [] }); + + await fetchStationTimeline(BASE_URL, { id: "noaa/8722588" }); + + const url = (fetch as ReturnType).mock.calls[0][0] as string; + expect(new URL(url).pathname).toBe("/tides/stations/noaa/8722588/timeline"); + }); +}); diff --git a/packages/react/test/components/NearbyStations.test.tsx b/packages/react/test/components/NearbyStations.test.tsx new file mode 100644 index 00000000..1fc0e8cc --- /dev/null +++ b/packages/react/test/components/NearbyStations.test.tsx @@ -0,0 +1,53 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import { NearbyStations } from "../../src/components/NearbyStations.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("NearbyStations", () => { + test("shows loading state initially", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText(/Loading/)).toBeDefined(); + }); + + test("renders station list as buttons", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + const buttons = view.getAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); + + // Each button should contain station name text + for (const button of buttons) { + expect(button.textContent!.length).toBeGreaterThan(0); + } + }); + + test("applies className", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(container.querySelector(".my-list")).toBeDefined(); + }); +}); diff --git a/packages/react/test/components/NightBands.test.tsx b/packages/react/test/components/NightBands.test.tsx new file mode 100644 index 00000000..e6ab0147 --- /dev/null +++ b/packages/react/test/components/NightBands.test.tsx @@ -0,0 +1,48 @@ +import { describe, test, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { scaleTime } from "@visx/scale"; +import { NightBands } from "../../src/components/TideGraph/NightBands.js"; + +describe("NightBands", () => { + const start = new Date("2025-12-17T00:00:00Z"); + const end = new Date("2025-12-18T00:00:00Z"); + + const xScale = scaleTime({ + domain: [start, end], + range: [0, 600], + }); + + test("renders night band rectangles for a location", () => { + // Boston, December — should have at least one night interval + const { container } = render( + + + , + ); + + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBeGreaterThanOrEqual(0); + }); + + test("renders no rectangles when coordinates are missing", () => { + const { container } = render( + + + , + ); + + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBe(0); + }); + + test("renders no rectangles when only latitude is provided", () => { + const { container } = render( + + + , + ); + + const rects = container.querySelectorAll("rect"); + expect(rects.length).toBe(0); + }); +}); diff --git a/packages/react/test/components/StationDisclaimers.test.tsx b/packages/react/test/components/StationDisclaimers.test.tsx new file mode 100644 index 00000000..978931be --- /dev/null +++ b/packages/react/test/components/StationDisclaimers.test.tsx @@ -0,0 +1,39 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { StationDisclaimers } from "../../src/components/StationDisclaimers.js"; + +describe("StationDisclaimers", () => { + test("renders nothing when disclaimers is undefined", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + test("renders nothing when disclaimers is empty string", () => { + const { container } = render(); + // falsy string returns null + expect(container.innerHTML).toBe(""); + }); + + test("renders disclaimer text", () => { + const text = "Data is for reference only. Not for navigation."; + const { container } = render(); + + const view = within(container); + expect(view.getByText(text)).toBeDefined(); + }); + + test("renders as a paragraph element", () => { + const { container } = render(); + + const p = container.querySelector("p"); + expect(p).not.toBeNull(); + expect(p!.textContent).toBe("Some disclaimer"); + }); + + test("applies className", () => { + const { container } = render(); + + const p = container.querySelector("p"); + expect(p!.className).toContain("my-class"); + }); +}); diff --git a/packages/react/test/components/StationSearch.test.tsx b/packages/react/test/components/StationSearch.test.tsx new file mode 100644 index 00000000..d2fb6bb7 --- /dev/null +++ b/packages/react/test/components/StationSearch.test.tsx @@ -0,0 +1,103 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { render, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StationSearch } from "../../src/components/StationSearch.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("StationSearch", () => { + beforeEach(() => { + localStorage.clear(); + }); + + test("renders input with default placeholder", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByPlaceholderText("Search stations...")).toBeDefined(); + }); + + test("renders input with custom placeholder", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + expect(view.getByPlaceholderText("Find a station...")).toBeDefined(); + }); + + test("has combobox role with correct ARIA attributes", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(input.getAttribute("autocomplete")).toBe("off"); + }); + + test("does not show dropdown when query is too short", async () => { + const user = userEvent.setup(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.type(input, "B"); + + expect(view.queryByRole("listbox")).toBeNull(); + }); + + test("closes dropdown on Escape", async () => { + const user = userEvent.setup(); + + // Seed recent searches so dropdown opens on focus + localStorage.setItem( + "neaps-recent-searches", + JSON.stringify([{ id: "noaa/8443970", name: "Boston", region: "MA", country: "US" }]), + ); + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.click(input); + + expect(view.getByRole("listbox")).toBeDefined(); + + await user.keyboard("{Escape}"); + + expect(view.queryByRole("listbox")).toBeNull(); + }); + + test("saves selected station to recent searches", async () => { + const user = userEvent.setup(); + + // Seed recent searches + localStorage.setItem( + "neaps-recent-searches", + JSON.stringify([{ id: "noaa/8443970", name: "Boston", region: "MA", country: "US" }]), + ); + + const onSelect = vi.fn(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.click(input); + + const option = view.getAllByRole("option")[0]; + await user.click(option); + + const recent = JSON.parse(localStorage.getItem("neaps-recent-searches") ?? "[]"); + expect(recent.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/test/components/TideConditions.test.tsx b/packages/react/test/components/TideConditions.test.tsx new file mode 100644 index 00000000..b1d38aab --- /dev/null +++ b/packages/react/test/components/TideConditions.test.tsx @@ -0,0 +1,231 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { WaterLevelAtTime, TideConditions } from "../../src/components/TideConditions.js"; +import { createTestWrapper } from "../helpers.js"; +import type { Extreme, TimelineEntry } from "../../src/types.js"; + +// Generate a simple timeline around "now" for testing TideConditionsStatic +const NOW = Date.now(); +const HALF_CYCLE = 6.2083 * 60 * 60 * 1000; // ~6h 12.5m + +function makeTimeline(count: number): TimelineEntry[] { + const start = NOW - HALF_CYCLE; + const step = (2 * HALF_CYCLE) / (count - 1); + return Array.from({ length: count }, (_, i) => ({ + time: new Date(start + i * step), + level: Math.sin((i / (count - 1)) * Math.PI * 2) * 1.5, + })); +} + +const timeline = makeTimeline(50); + +const extremes: Extreme[] = [ + { time: new Date(NOW - 3 * 60 * 60 * 1000), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date(NOW + 3 * 60 * 60 * 1000), level: -0.3, high: false, low: true, label: "Low" }, +]; + +describe("WaterLevelAtTime", () => { + test("renders label, level, and time", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.getByText("Now")).toBeDefined(); + expect(view.getByText("1.50 m")).toBeDefined(); + }); + + test("renders state icon when state is provided", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.getByLabelText("Rising")).toBeDefined(); + }); + + test("renders falling state", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.getByLabelText("Falling")).toBeDefined(); + expect(view.getByText("0.2 ft")).toBeDefined(); + }); + + test("renders high tide state", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.getByLabelText("High tide")).toBeDefined(); + }); + + test("renders low tide state", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.getByLabelText("Low tide")).toBeDefined(); + }); + + test("does not render state icon when no state", () => { + const { container } = render( + , + ); + const view = within(container); + + expect(view.queryByLabelText("Rising")).toBeNull(); + expect(view.queryByLabelText("Falling")).toBeNull(); + expect(view.queryByLabelText("High tide")).toBeNull(); + expect(view.queryByLabelText("Low tide")).toBeNull(); + }); + + test("applies right variant alignment", () => { + const { container } = render( + , + ); + + expect(container.firstElementChild!.className).toContain("items-end"); + }); +}); + +describe("TideConditions", () => { + test("renders with data props", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + // Should show "Now" label + expect(view.getByText("Now")).toBeDefined(); + // Should show "Next" label + expect(view.getByText("Next")).toBeDefined(); + }); + + test("shows 'No tide data available' with empty timeline", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + expect(view.getByText("No tide data available")).toBeDefined(); + }); + + test("applies className", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector(".my-conditions")).not.toBeNull(); + }); + + test("shows rising indicator when next extreme is high", () => { + const risingExtremes: Extreme[] = [ + { + time: new Date(NOW + 3 * 60 * 60 * 1000), + level: 2.0, + high: true, + low: false, + label: "High", + }, + ]; + + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + expect(view.getByLabelText("Rising")).toBeDefined(); + }); + + test("shows falling indicator when next extreme is low", () => { + const fallingExtremes: Extreme[] = [ + { + time: new Date(NOW + 3 * 60 * 60 * 1000), + level: -0.3, + high: false, + low: true, + label: "Low", + }, + ]; + + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + expect(view.getByLabelText("Falling")).toBeDefined(); + }); +}); diff --git a/packages/react/test/components/TideCycleGraph.test.tsx b/packages/react/test/components/TideCycleGraph.test.tsx new file mode 100644 index 00000000..2b9ce162 --- /dev/null +++ b/packages/react/test/components/TideCycleGraph.test.tsx @@ -0,0 +1,52 @@ +import { describe, test, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { TideCycleGraph } from "../../src/components/TideCycleGraph.js"; +import { createTestWrapper } from "../helpers.js"; +import type { Extreme, TimelineEntry } from "../../src/types.js"; + +const NOW = Date.now(); +const HALF_CYCLE = 6.2083 * 60 * 60 * 1000; + +function makeTimeline(count: number): TimelineEntry[] { + const start = NOW - HALF_CYCLE; + const step = (2 * HALF_CYCLE) / (count - 1); + return Array.from({ length: count }, (_, i) => ({ + time: new Date(start + i * step), + level: Math.sin((i / (count - 1)) * Math.PI * 2) * 1.5, + })); +} + +const timeline = makeTimeline(50); + +const extremes: Extreme[] = [ + { time: new Date(NOW - 3 * 60 * 60 * 1000), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date(NOW + 3 * 60 * 60 * 1000), level: -0.3, high: false, low: true, label: "Low" }, +]; + +describe("TideCycleGraph", () => { + test("renders a container div", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + expect(container.firstElementChild).toBeDefined(); + }); + + test("returns null with empty timeline", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + // The component returns null when windowTimeline is empty + expect(container.innerHTML).toBe(""); + }); + + test("applies className", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector(".my-graph")).not.toBeNull(); + }); +}); diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx new file mode 100644 index 00000000..b48349d3 --- /dev/null +++ b/packages/react/test/components/TideGraph.test.tsx @@ -0,0 +1,56 @@ +import { describe, test, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { TideGraphChart } from "../../src/components/TideGraph/index.js"; +import { createTestWrapper } from "../helpers.js"; + +const timeline = [ + { time: new Date("2025-12-17T00:00:00Z"), level: 0.5 }, + { time: new Date("2025-12-17T03:00:00Z"), level: 1.2 }, + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5 }, + { time: new Date("2025-12-17T09:00:00Z"), level: 0.8 }, + { time: new Date("2025-12-17T12:00:00Z"), level: 0.3 }, + { time: new Date("2025-12-17T15:00:00Z"), level: 0.9 }, + { time: new Date("2025-12-17T18:00:00Z"), level: 1.4 }, + { time: new Date("2025-12-17T21:00:00Z"), level: 0.7 }, +]; + +const extremes = [ + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T12:00:00Z"), level: 0.3, high: false, low: true, label: "Low" }, +]; + +const noop = () => {}; + +describe("TideGraphChart", () => { + test("renders an svg element", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector("svg")).not.toBeNull(); + }); + + test("renders with empty extremes", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/packages/react/test/components/TideGraphFull.test.tsx b/packages/react/test/components/TideGraphFull.test.tsx new file mode 100644 index 00000000..534a434e --- /dev/null +++ b/packages/react/test/components/TideGraphFull.test.tsx @@ -0,0 +1,175 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import { TideGraph } from "../../src/components/TideGraph/TideGraph.js"; +import { TideGraphChart } from "../../src/components/TideGraph/TideGraphChart.js"; +import { createTestWrapper } from "../helpers.js"; +import type { TimelineEntry, Extreme } from "../../src/types.js"; + +const timeline: TimelineEntry[] = [ + { time: new Date("2025-12-17T00:00:00Z"), level: 0.5 }, + { time: new Date("2025-12-17T03:00:00Z"), level: 1.2 }, + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5 }, + { time: new Date("2025-12-17T09:00:00Z"), level: 0.8 }, + { time: new Date("2025-12-17T12:00:00Z"), level: 0.3 }, + { time: new Date("2025-12-17T15:00:00Z"), level: 0.9 }, + { time: new Date("2025-12-17T18:00:00Z"), level: 1.4 }, + { time: new Date("2025-12-17T21:00:00Z"), level: 0.7 }, +]; + +const extremes: Extreme[] = [ + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T12:00:00Z"), level: 0.3, high: false, low: true, label: "Low" }, +]; + +const noop = () => {}; + +describe("TideGraphChart", () => { + test("renders with active entry tooltip", () => { + const activeEntry: TimelineEntry = { + time: new Date("2025-12-17T09:00:00Z"), + level: 0.8, + }; + + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + // Active entry renders a level label + expect(container.textContent).toContain("0.80 m"); + }); + + test("renders with coordinates (night bands + daylight axis)", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + }); + + test("renders with yDomainOverride", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + }); + + test("renders nothing with zero width", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector("svg")).toBeNull(); + }); + + test("renders extreme point labels", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + // Should show high and low labels + expect(container.textContent).toContain("1.50 m"); + expect(container.textContent).toContain("0.30 m"); + }); +}); + +describe("TideGraph (scrollable wrapper)", () => { + test("shows loading state initially", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText(/Loading tide data/)).toBeDefined(); + }); + + test("renders scrollable region after loading", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading tide data/)).toBeNull(); + }, + { timeout: 15000 }, + ); + + const region = view.getByRole("region", { name: /scrollable/i }); + expect(region).toBeDefined(); + }); + + test("applies className", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + expect(container.querySelector(".my-graph")).not.toBeNull(); + }); + + test("renders Now button", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading tide data/)).toBeNull(); + }, + { timeout: 15000 }, + ); + + const nowButton = view.getByLabelText("Scroll to current time"); + expect(nowButton).toBeDefined(); + }); +}); diff --git a/packages/react/test/components/TideSettings.test.tsx b/packages/react/test/components/TideSettings.test.tsx new file mode 100644 index 00000000..40561860 --- /dev/null +++ b/packages/react/test/components/TideSettings.test.tsx @@ -0,0 +1,95 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { TideSettings } from "../../src/components/TideSettings.js"; +import { createTestWrapper } from "../helpers.js"; + +const station = { + datums: { MLLW: 0, MSL: 1.5, MHHW: 3.0 }, + defaultDatum: "MLLW", + timezone: "America/New_York", +}; + +describe("TideSettings", () => { + test("renders units select with options", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const group = view.getByRole("group", { name: /Tide display settings/i }); + expect(group).toBeDefined(); + + // Should have units dropdown + expect(view.getByText("Units")).toBeDefined(); + expect(view.getByText("Metric (m)")).toBeDefined(); + expect(view.getByText("Imperial (ft)")).toBeDefined(); + }); + + test("renders datum select when multiple datums", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText("Datum")).toBeDefined(); + // Should show default datum option + expect(view.getByText("SD (MLLW)")).toBeDefined(); + }); + + test("does not render datum select with single datum", () => { + const singleDatum = { ...station, datums: { MLLW: 0 } }; + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.queryByText("Datum")).toBeNull(); + }); + + test("renders timezone select", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText("Timezone")).toBeDefined(); + expect(view.getByText(/Station/)).toBeDefined(); + }); + + test("hides timezone select when only station timezone available", () => { + // When station timezone equals browser timezone and there's no UTC difference + const utcStation = { ...station, timezone: "UTC" }; + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + // UTC station, browser may or may not be UTC, but we should not crash + // The timezone select may or may not be shown based on browser timezone + expect(view.getByText("Units")).toBeDefined(); + }); + + test("changing units updates config", async () => { + const user = userEvent.setup(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const selects = view.getAllByRole("combobox"); + const unitSelect = selects[0]; + + await user.selectOptions(unitSelect, "meters"); + expect((unitSelect as HTMLSelectElement).value).toBe("meters"); + }); + + test("applies className", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const group = container.querySelector("[role='group']"); + expect(group!.className).toContain("my-settings"); + }); +}); diff --git a/packages/react/test/components/TideStation.test.tsx b/packages/react/test/components/TideStation.test.tsx new file mode 100644 index 00000000..2d31784f --- /dev/null +++ b/packages/react/test/components/TideStation.test.tsx @@ -0,0 +1,101 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import { TideStation } from "../../src/components/TideStation.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("TideStation", () => { + test("shows loading state initially", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText("Loading...")).toBeDefined(); + }); + + test("renders station name and region after loading", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + // Station name should be in an h3 + const heading = view.getByRole("heading", { level: 1 }); + expect(heading.textContent!.length).toBeGreaterThan(0); + }); + + test("shows current level with arrow indicator", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + // Should have a Rising or Falling indicator + const rising = view.queryByLabelText("Rising"); + const falling = view.queryByLabelText("Falling"); + expect(rising ?? falling).toBeDefined(); + }); + + test("renders graph and table by default", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(container.querySelector("canvas")).toBeDefined(); + expect(view.queryByRole("table")).toBeDefined(); + }); + + test("renders table when showTable is true", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(view.getByRole("table")).toBeDefined(); + }); + + test("applies className", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(container.querySelector(".my-station")).toBeDefined(); + }); +}); diff --git a/packages/react/test/components/TideStationHeader.test.tsx b/packages/react/test/components/TideStationHeader.test.tsx new file mode 100644 index 00000000..0c156371 --- /dev/null +++ b/packages/react/test/components/TideStationHeader.test.tsx @@ -0,0 +1,52 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { TideStationHeader } from "../../src/components/TideStationHeader.js"; + +describe("TideStationHeader", () => { + const station = { + name: "Boston", + region: "Massachusetts", + country: "US", + latitude: 42.3547, + longitude: -71.0534, + }; + + test("renders station name as heading", () => { + const { container } = render(); + const view = within(container); + + const heading = view.getByRole("heading", { level: 1 }); + expect(heading.textContent).toBe("Boston"); + }); + + test("renders region and country", () => { + const { container } = render(); + const view = within(container); + + expect(view.getByText(/Massachusetts/)).toBeDefined(); + expect(view.getByText(/US/)).toBeDefined(); + }); + + test("renders formatted coordinates", () => { + const { container } = render(); + + // Should contain coordinate-format output (degrees/minutes) + expect(container.textContent).toContain("71"); + expect(container.textContent).toContain("42"); + }); + + test("handles missing region gracefully", () => { + const stationNoRegion = { ...station, region: "", country: "US" }; + const { container } = render(); + const view = within(container); + + // Should still render without error + expect(view.getByRole("heading", { level: 1 })).toBeDefined(); + }); + + test("applies className", () => { + const { container } = render(); + + expect(container.firstElementChild!.className).toContain("my-header"); + }); +}); diff --git a/packages/react/test/components/TideTable.test.tsx b/packages/react/test/components/TideTable.test.tsx new file mode 100644 index 00000000..4a7cb242 --- /dev/null +++ b/packages/react/test/components/TideTable.test.tsx @@ -0,0 +1,68 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { TideTable } from "../../src/components/TideTable.js"; +import { createTestWrapper } from "../helpers.js"; + +const extremes = [ + { time: new Date("2025-12-17T04:30:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T10:45:00Z"), level: 0.2, high: false, low: true, label: "Low" }, + { time: new Date("2025-12-17T16:00:00Z"), level: 1.4, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T22:15:00Z"), level: 0.3, high: false, low: true, label: "Low" }, +]; + +describe("TideTable", () => { + test("renders table with correct structure", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const view = within(container); + expect(view.getByRole("table")).toBeDefined(); + expect(view.getAllByRole("columnheader").length).toBeGreaterThanOrEqual(3); + }); + + test("renders all extremes as rows", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const view = within(container); + const rows = view.getAllByRole("row"); + // 1 header row + 4 data rows + expect(rows.length).toBe(5); + }); + + test("displays High and Low labels", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const view = within(container); + const highLabels = view.getAllByText("High"); + const lowLabels = view.getAllByText("Low"); + expect(highLabels.length).toBe(2); + expect(lowLabels.length).toBe(2); + }); + + test("renders empty table when no extremes", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const view = within(container); + expect(view.getByRole("table")).toBeDefined(); + // Header row only + const rows = view.getAllByRole("row"); + expect(rows.length).toBe(1); + }); + + test("formats levels with units", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const view = within(container); + expect(view.getAllByText("1.50 m").length).toBeGreaterThan(0); + expect(view.getAllByText("0.20 m").length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/test/components/TideTableFetcher.test.tsx b/packages/react/test/components/TideTableFetcher.test.tsx new file mode 100644 index 00000000..82ac2dc4 --- /dev/null +++ b/packages/react/test/components/TideTableFetcher.test.tsx @@ -0,0 +1,109 @@ +import { describe, test, expect } from "vitest"; +import { render, within } from "@testing-library/react"; +import { TideTable } from "../../src/components/TideTable.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("TideTable data grouping and state", () => { + test("groups extremes by date", () => { + // Two days of extremes + const extremes = [ + { time: new Date("2025-12-17T04:30:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T10:45:00Z"), level: 0.2, high: false, low: true, label: "Low" }, + { time: new Date("2025-12-18T05:00:00Z"), level: 1.4, high: true, low: false, label: "High" }, + { time: new Date("2025-12-18T11:15:00Z"), level: 0.3, high: false, low: true, label: "Low" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + // Should show both dates + expect(view.getByText(/Dec 17/)).toBeDefined(); + expect(view.getByText(/Dec 18/)).toBeDefined(); + }); + + test("highlights next upcoming extreme", () => { + const now = new Date(); + const upcoming = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour ahead + const past = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago + + const extremes = [ + { time: past, level: 1.5, high: true, low: false, label: "High" }, + { time: upcoming, level: 0.2, high: false, low: true, label: "Low" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + // The next upcoming row should have aria-current="true" + const currentRow = container.querySelector('[aria-current="true"]'); + expect(currentRow).not.toBeNull(); + }); + + test("does not highlight when all extremes are in the past", () => { + const past1 = new Date(Date.now() - 3 * 60 * 60 * 1000); + const past2 = new Date(Date.now() - 1 * 60 * 60 * 1000); + + const extremes = [ + { time: past1, level: 1.5, high: true, low: false, label: "High" }, + { time: past2, level: 0.2, high: false, low: true, label: "Low" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const currentRow = container.querySelector('[aria-current="true"]'); + expect(currentRow).toBeNull(); + }); + + test("renders date in first row with rowspan", () => { + const extremes = [ + { time: new Date("2025-12-17T04:30:00Z"), level: 1.5, high: true, low: false, label: "High" }, + { time: new Date("2025-12-17T10:45:00Z"), level: 0.2, high: false, low: true, label: "Low" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + // First row has the date cell with rowspan + const rowspanCells = container.querySelectorAll("td[rowspan]"); + expect(rowspanCells.length).toBe(1); + expect(rowspanCells[0].getAttribute("rowspan")).toBe("2"); + }); + + test("renders with feet units", () => { + const extremes = [ + { + time: new Date("2025-12-17T06:00:00Z"), + level: 4.78, + high: true, + low: false, + label: "High", + }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText("4.8 ft")).toBeDefined(); + }); + + test("uses provider units when none specified", () => { + const extremes = [ + { time: new Date("2025-12-17T06:00:00Z"), level: 1.5, high: true, low: false, label: "High" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper({ units: "feet" }), + }); + const view = within(container); + + expect(view.getByText("1.5 ft")).toBeDefined(); + }); +}); diff --git a/packages/react/test/components/YAxisOverlay.test.tsx b/packages/react/test/components/YAxisOverlay.test.tsx new file mode 100644 index 00000000..dd7261cd --- /dev/null +++ b/packages/react/test/components/YAxisOverlay.test.tsx @@ -0,0 +1,66 @@ +import { describe, test, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { scaleLinear } from "@visx/scale"; +import { YAxisOverlay } from "../../src/components/TideGraph/YAxisOverlay.js"; + +describe("YAxisOverlay", () => { + const yScale = scaleLinear({ + domain: [-1, 3], + range: [200, 0], + nice: true, + }); + + test("renders", () => { + const { container } = render( + , + ); + + const div = container.firstElementChild as HTMLElement; + expect(div).toBeDefined(); + expect(div.style.position || div.className).toBeTruthy(); + + const svg = container.querySelector("svg"); + expect(svg).not.toBeNull(); + }); + + test("includes unit suffix in tick labels", () => { + const { container } = render( + , + ); + + // Should contain "ft" somewhere in tick labels + expect(container.textContent).toContain("ft"); + }); + + test("formats narrow range with decimal places", () => { + const narrowYScale = scaleLinear({ + domain: [0.5, 1.5], + range: [200, 0], + nice: true, + }); + + const { container } = render( + , + ); + + // narrowRange uses toFixed(1), so tick labels should contain a decimal point + const text = container.textContent!; + expect(text).toMatch(/\d\.\d m/); + }); + + test("formats wide range with rounded numbers", () => { + const wideYScale = scaleLinear({ + domain: [-1, 4], + range: [200, 0], + nice: true, + }); + + const { container } = render( + , + ); + + // Wide range uses Math.round, so tick labels should be integers without decimals + const text = container.textContent!; + expect(text).toContain("-1 m"); + }); +}); diff --git a/packages/react/test/defaults.test.ts b/packages/react/test/defaults.test.ts new file mode 100644 index 00000000..a250a3a8 --- /dev/null +++ b/packages/react/test/defaults.test.ts @@ -0,0 +1,59 @@ +import { describe, test, expect } from "vitest"; +import { getDefaultUnits, getDefaultRange } from "../src/utils/defaults.js"; + +describe("getDefaultUnits", () => { + test("returns feet for en-US", () => { + expect(getDefaultUnits("en-US")).toBe("feet"); + }); + + test("returns feet for en-LR (Liberia)", () => { + expect(getDefaultUnits("en-LR")).toBe("feet"); + }); + + test("returns feet for my-MM (Myanmar)", () => { + expect(getDefaultUnits("my-MM")).toBe("feet"); + }); + + test("returns meters for en-GB", () => { + expect(getDefaultUnits("en-GB")).toBe("meters"); + }); + + test("returns meters for fr-FR", () => { + expect(getDefaultUnits("fr-FR")).toBe("meters"); + }); + + test("returns meters for ja-JP", () => { + expect(getDefaultUnits("ja-JP")).toBe("meters"); + }); + + test("falls back to navigator.language when no locale provided", () => { + // In test environment (en-US), should return feet + const result = getDefaultUnits(); + expect(["feet", "meters"]).toContain(result); + }); +}); + +describe("getDefaultRange", () => { + test("returns start and end as ISO strings", () => { + const { start, end } = getDefaultRange(); + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + test("start has hours/minutes/seconds zeroed out (local midnight)", () => { + const { start } = getDefaultRange(); + const date = new Date(start); + // getDefaultRange uses setHours(0,0,0,0) which zeros local time components + expect(date.getMinutes()).toBe(0); + expect(date.getSeconds()).toBe(0); + expect(date.getMilliseconds()).toBe(0); + }); + + test("end is 7 days after start", () => { + const { start, end } = getDefaultRange(); + const startDate = new Date(start); + const endDate = new Date(end); + const diffDays = (endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000); + expect(diffDays).toBe(7); + }); +}); diff --git a/packages/react/test/format.test.ts b/packages/react/test/format.test.ts new file mode 100644 index 00000000..df388643 --- /dev/null +++ b/packages/react/test/format.test.ts @@ -0,0 +1,71 @@ +import { describe, test, expect } from "vitest"; +import { + formatLevel, + formatTime, + formatDate, + formatDistance, + getDateKey, +} from "../src/utils/format.js"; + +describe("formatLevel", () => { + test("meters with 2 decimal places", () => { + expect(formatLevel(1.4567, "meters")).toBe("1.46 m"); + }); + + test("feet with 1 decimal place", () => { + expect(formatLevel(4.78, "feet")).toBe("4.8 ft"); + }); + + test("zero", () => { + expect(formatLevel(0, "meters")).toBe("0.00 m"); + }); + + test("negative values", () => { + expect(formatLevel(-0.25, "meters")).toBe("-0.25 m"); + }); +}); + +describe("formatTime", () => { + test("formats time in given timezone", () => { + const result = formatTime(new Date("2025-12-17T10:23:00Z"), "America/New_York"); + expect(result).toBe("5:23 AM"); + }); + + test("UTC timezone", () => { + const result = formatTime(new Date("2025-12-17T10:23:00Z"), "UTC"); + expect(result).toBe("10:23 AM"); + }); +}); + +describe("formatDate", () => { + test("formats date with weekday, month, day", () => { + const result = formatDate(new Date("2025-12-17T10:00:00Z"), "UTC"); + expect(result).toBe("Wed, Dec 17"); + }); +}); + +describe("formatDistance", () => { + test("short distance in meters", () => { + expect(formatDistance(450, "meters")).toBe("450 m"); + }); + + test("long distance in kilometers", () => { + expect(formatDistance(2500, "meters")).toBe("2.5 km"); + }); + + test("short distance in feet", () => { + expect(formatDistance(25, "feet")).toBe("82 ft"); + }); + + test("long distance in miles", () => { + expect(formatDistance(5000, "feet")).toBe("3.1 mi"); + }); +}); + +describe("getDateKey", () => { + test("returns YYYY-MM-DD in timezone", () => { + // 2025-12-17T02:00:00Z is still Dec 16 in New York (UTC-5) + expect(getDateKey(new Date("2025-12-17T02:00:00Z"), "America/New_York")).toBe("2025-12-16"); + expect(getDateKey(new Date("2025-12-17T02:00:00Z"), "UTC")).toBe("2025-12-17"); + }); +}); diff --git a/packages/react/test/globalSetup.ts b/packages/react/test/globalSetup.ts new file mode 100644 index 00000000..3303c25c --- /dev/null +++ b/packages/react/test/globalSetup.ts @@ -0,0 +1,21 @@ +import { createApp } from "@neaps/api"; +import type { TestProject } from "vitest/node"; + +export default function setup({ provide }: TestProject) { + const app = createApp(); + const server = app.listen(0); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const baseUrl = `http://localhost:${port}`; + provide("apiBaseUrl", baseUrl); + + return function teardown() { + server.close(); + }; +} + +declare module "vitest" { + export interface ProvidedContext { + apiBaseUrl: string; + } +} diff --git a/packages/react/test/helpers.tsx b/packages/react/test/helpers.tsx new file mode 100644 index 00000000..1bd9bb0e --- /dev/null +++ b/packages/react/test/helpers.tsx @@ -0,0 +1,25 @@ +import { inject } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { NeapsProvider, NeapsProviderProps } from "../src/provider.js"; +import type { ReactNode } from "react"; + +export function createTestWrapper({ + baseUrl = inject("apiBaseUrl"), + ...props +}: Partial = {}) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return function TestWrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} diff --git a/packages/react/test/hooks/use-extremes.test.tsx b/packages/react/test/hooks/use-extremes.test.tsx new file mode 100644 index 00000000..f0fb5e84 --- /dev/null +++ b/packages/react/test/hooks/use-extremes.test.tsx @@ -0,0 +1,62 @@ +import { describe, test, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useExtremes } from "../../src/hooks/use-extremes.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("useExtremes", () => { + test("fetches extremes by station ID", async () => { + const now = new Date(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const { result } = renderHook( + () => useExtremes({ id: "noaa/8443970", start: now.toISOString(), end: end.toISOString() }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(result.current.data!.extremes).toBeDefined(); + expect(Array.isArray(result.current.data!.extremes)).toBe(true); + expect(result.current.data!.datum).toBeDefined(); + }); + + test("inherits units from provider", async () => { + const now = new Date(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const { result } = renderHook( + () => useExtremes({ id: "noaa/8443970", start: now.toISOString(), end: end.toISOString() }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data!.units).toBeDefined(); + }); + + test("returns error for invalid station", async () => { + const { result } = renderHook(() => useExtremes({ id: "nonexistent/station" }), { + wrapper: createTestWrapper(), + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/packages/react/test/hooks/use-nearby-stations.test.tsx b/packages/react/test/hooks/use-nearby-stations.test.tsx new file mode 100644 index 00000000..4f68b2fd --- /dev/null +++ b/packages/react/test/hooks/use-nearby-stations.test.tsx @@ -0,0 +1,69 @@ +import { describe, test, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useNearbyStations } from "../../src/hooks/use-nearby-stations.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("useNearbyStations", () => { + test("fetches nearby stations by position", async () => { + const { result } = renderHook( + () => useNearbyStations({ latitude: 42.3541, longitude: -71.0495 }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(Array.isArray(result.current.data)).toBe(true); + expect(result.current.data!.length).toBeGreaterThan(0); + }); + + test("respects maxResults", async () => { + const { result } = renderHook( + () => useNearbyStations({ latitude: 42.3541, longitude: -71.0495, maxResults: 2 }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data!.length).toBeLessThanOrEqual(2); + }); + + test("is disabled when params are undefined", () => { + const { result } = renderHook(() => useNearbyStations(undefined), { + wrapper: createTestWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + test("station results have expected shape", async () => { + const { result } = renderHook( + () => useNearbyStations({ latitude: 42.3541, longitude: -71.0495, maxResults: 1 }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + const station = result.current.data![0]; + expect(station.id).toBeTypeOf("string"); + expect(station.name).toBeTypeOf("string"); + expect(station.latitude).toBeTypeOf("number"); + expect(station.longitude).toBeTypeOf("number"); + }); +}); diff --git a/packages/react/test/hooks/use-station.test.tsx b/packages/react/test/hooks/use-station.test.tsx new file mode 100644 index 00000000..d2710311 --- /dev/null +++ b/packages/react/test/hooks/use-station.test.tsx @@ -0,0 +1,50 @@ +import { describe, test, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useStation } from "../../src/hooks/use-station.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("useStation", () => { + test("fetches station data by ID", async () => { + const { result } = renderHook(() => useStation("noaa/8443970"), { + wrapper: createTestWrapper(), + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(result.current.data!.name).toBeDefined(); + expect(result.current.data!.latitude).toBeTypeOf("number"); + expect(result.current.data!.longitude).toBeTypeOf("number"); + }); + + test("is disabled when id is undefined", () => { + const { result } = renderHook(() => useStation(undefined), { + wrapper: createTestWrapper(), + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + test("returns error for invalid station", async () => { + const { result } = renderHook(() => useStation("nonexistent/station"), { + wrapper: createTestWrapper(), + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/packages/react/test/hooks/use-stations.test.tsx b/packages/react/test/hooks/use-stations.test.tsx new file mode 100644 index 00000000..698d7b3f --- /dev/null +++ b/packages/react/test/hooks/use-stations.test.tsx @@ -0,0 +1,57 @@ +import { describe, test, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useStations } from "../../src/hooks/use-stations.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("useStations", () => { + test("fetches all stations with no params", async () => { + const { result } = renderHook(() => useStations(), { + wrapper: createTestWrapper(), + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(Array.isArray(result.current.data)).toBe(true); + expect(result.current.data!.length).toBeGreaterThan(0); + }); + + test("searches stations by query", async () => { + const { result } = renderHook(() => useStations({ query: "Boston" }), { + wrapper: createTestWrapper(), + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(result.current.data!.length).toBeGreaterThan(0); + expect(result.current.data![0].name).toBeDefined(); + }); + + test("searches stations by proximity", async () => { + const { result } = renderHook( + () => useStations({ latitude: 42.3541, longitude: -71.0495, maxResults: 3 }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(result.current.data!.length).toBeLessThanOrEqual(3); + }); +}); diff --git a/packages/react/test/hooks/use-timeline.test.tsx b/packages/react/test/hooks/use-timeline.test.tsx new file mode 100644 index 00000000..ea348481 --- /dev/null +++ b/packages/react/test/hooks/use-timeline.test.tsx @@ -0,0 +1,64 @@ +import { describe, test, expect } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { useTimeline } from "../../src/hooks/use-timeline.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("useTimeline", () => { + test("fetches timeline by station ID", async () => { + const now = new Date(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const { result } = renderHook( + () => useTimeline({ id: "noaa/8443970", start: now.toISOString(), end: end.toISOString() }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.data).toBeDefined(); + expect(result.current.data!.timeline).toBeDefined(); + expect(Array.isArray(result.current.data!.timeline)).toBe(true); + expect(result.current.data!.timeline.length).toBeGreaterThan(0); + }); + + test("timeline entries have time and level", async () => { + const now = new Date(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + const { result } = renderHook( + () => useTimeline({ id: "noaa/8443970", start: now.toISOString(), end: end.toISOString() }), + { wrapper: createTestWrapper() }, + ); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + const entry = result.current.data!.timeline[0]; + expect(entry.time).toBeInstanceOf(Date); + expect(entry.level).toBeTypeOf("number"); + }); + + test("returns error for invalid station", async () => { + const { result } = renderHook(() => useTimeline({ id: "nonexistent/station" }), { + wrapper: createTestWrapper(), + }); + + await waitFor( + () => { + expect(result.current.isLoading).toBe(false); + }, + { timeout: 10000 }, + ); + + expect(result.current.error).toBeDefined(); + }); +}); diff --git a/packages/react/test/integration/NearbyStations.test.tsx b/packages/react/test/integration/NearbyStations.test.tsx new file mode 100644 index 00000000..1cf138da --- /dev/null +++ b/packages/react/test/integration/NearbyStations.test.tsx @@ -0,0 +1,85 @@ +import { describe, test, expect, vi } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NearbyStations } from "../../src/components/NearbyStations.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("NearbyStations integration", () => { + test("renders nearby stations by station ID", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByText(/Loading/)).toBeDefined(); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + const buttons = view.getAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); + }); + + test("renders nearby stations by lat/lng", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + const buttons = view.getAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); + }); + + test("limits results with maxResults", async () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + const buttons = view.getAllByRole("button"); + expect(buttons.length).toBeLessThanOrEqual(2); + }); + + test("calls onStationSelect when a station is clicked", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText(/Loading/)).toBeNull(); + }, + { timeout: 10000 }, + ); + + const firstButton = view.getAllByRole("button")[0]; + await user.click(firstButton); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect.mock.calls[0][0]).toHaveProperty("id"); + }); +}); diff --git a/packages/react/test/integration/StationSearch.test.tsx b/packages/react/test/integration/StationSearch.test.tsx new file mode 100644 index 00000000..54e1fdfb --- /dev/null +++ b/packages/react/test/integration/StationSearch.test.tsx @@ -0,0 +1,89 @@ +import { describe, test, expect, vi, beforeEach } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { StationSearch } from "../../src/components/StationSearch.js"; +import { createTestWrapper } from "../helpers.js"; + +describe("StationSearch integration", () => { + beforeEach(() => localStorage.clear()); + + test("renders search input", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + expect(view.getByRole("combobox")).toBeDefined(); + }); + + test("shows results when typing a query", async () => { + const user = userEvent.setup(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.type(input, "Boston"); + + await waitFor( + () => { + expect(view.getByRole("listbox")).toBeDefined(); + }, + { timeout: 10000 }, + ); + + const options = view.getAllByRole("option"); + expect(options.length).toBeGreaterThan(0); + }); + + test("calls onSelect when a result is clicked", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.type(input, "Boston"); + + await waitFor( + () => { + expect(view.getByRole("listbox")).toBeDefined(); + }, + { timeout: 10000 }, + ); + + const option = view.getAllByRole("option")[0]; + await user.click(option); + + expect(onSelect).toHaveBeenCalledOnce(); + expect(onSelect.mock.calls[0][0]).toHaveProperty("id"); + expect(onSelect.mock.calls[0][0]).toHaveProperty("name"); + }); + + test("shows recent searches when focused with empty query", async () => { + // Seed localStorage + const recent = [{ id: "noaa/8443970", name: "Boston", region: "MA", country: "US" }]; + localStorage.setItem("neaps-recent-searches", JSON.stringify(recent)); + + const user = userEvent.setup(); + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + const input = view.getByRole("combobox"); + await user.click(input); + + await waitFor(() => { + expect(view.getByText("Recent")).toBeDefined(); + }); + + expect(view.getByText("Boston")).toBeDefined(); + + // Clean up + localStorage.removeItem("neaps-recent-searches"); + }); +}); diff --git a/packages/react/test/integration/TideStation.test.tsx b/packages/react/test/integration/TideStation.test.tsx new file mode 100644 index 00000000..d6a40e7f --- /dev/null +++ b/packages/react/test/integration/TideStation.test.tsx @@ -0,0 +1,71 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import { TideStation } from "../../src/components/TideStation.js"; +import { createTestWrapper } from "../helpers.js"; + +const STATION_ID = "noaa/8443970"; + +describe("TideStation integration", () => { + test("renders station name after loading", async () => { + const { container } = render(, { wrapper: createTestWrapper() }); + const view = within(container); + + expect(view.getByText("Loading...")).toBeDefined(); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(view.getByRole("heading", { level: 1 })).toBeDefined(); + }); + + test("renders graph by default", async () => { + const { container } = render(, { wrapper: createTestWrapper() }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(container.querySelector("canvas")).toBeDefined(); + }); + + test("renders table when showTable is true", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + expect(view.getByRole("table")).toBeDefined(); + }); + + test("shows error for invalid station", async () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + // Should show an error message instead of station content + expect(view.queryByRole("heading", { level: 1 })).toBeNull(); + }); +}); diff --git a/packages/react/test/provider.test.tsx b/packages/react/test/provider.test.tsx new file mode 100644 index 00000000..d217d358 --- /dev/null +++ b/packages/react/test/provider.test.tsx @@ -0,0 +1,149 @@ +import { describe, test, expect, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { NeapsProvider, useNeapsConfig, useUpdateConfig } from "../src/provider.js"; +import type { ReactNode } from "react"; + +beforeEach(() => { + localStorage.removeItem("neaps-settings"); +}); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +describe("NeapsProvider", () => { + test("provides config to consumers", () => { + const { result } = renderHook(() => useNeapsConfig(), { wrapper }); + + expect(result.current).toEqual({ + baseUrl: "https://api.example.com", + units: "feet", + datum: "MLLW", + locale: "en-US", + }); + }); + + test("defaults units based on locale", () => { + const minimalWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useNeapsConfig(), { wrapper: minimalWrapper }); + + // en-US defaults to feet; non-US locales default to meters + expect(result.current.units).toBe("feet"); + expect(result.current.datum).toBeUndefined(); + }); + + test("defaults timezone to undefined", () => { + const minimalWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useNeapsConfig(), { wrapper: minimalWrapper }); + expect(result.current.timezone).toBeUndefined(); + }); + + test("applies initial datum prop", () => { + const datumWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNeapsConfig(), { wrapper: datumWrapper }); + expect(result.current.datum).toBe("MSL"); + }); + + test("applies initial timezone prop", () => { + const tzWrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNeapsConfig(), { wrapper: tzWrapper }); + expect(result.current.timezone).toBe("America/Los_Angeles"); + }); + + test("throws when useNeapsConfig is used outside provider", () => { + expect(() => { + renderHook(() => useNeapsConfig()); + }).toThrow("useNeapsConfig must be used within a "); + }); + + test("throws when useUpdateConfig is used outside provider", () => { + expect(() => { + renderHook(() => useUpdateConfig()); + }).toThrow("useUpdateConfig must be used within a "); + }); +}); + +describe("useUpdateConfig", () => { + test("updates units", () => { + const { result } = renderHook(() => ({ config: useNeapsConfig(), update: useUpdateConfig() }), { + wrapper, + }); + + expect(result.current.config.units).toBe("feet"); + + act(() => { + result.current.update({ units: "meters" }); + }); + + expect(result.current.config.units).toBe("meters"); + }); + + test("updates datum", () => { + const { result } = renderHook(() => ({ config: useNeapsConfig(), update: useUpdateConfig() }), { + wrapper, + }); + + act(() => { + result.current.update({ datum: "MSL" }); + }); + + expect(result.current.config.datum).toBe("MSL"); + }); + + test("updates timezone", () => { + const { result } = renderHook(() => ({ config: useNeapsConfig(), update: useUpdateConfig() }), { + wrapper, + }); + + act(() => { + result.current.update({ timezone: "UTC" }); + }); + + expect(result.current.config.timezone).toBe("UTC"); + }); + + test("updates locale", () => { + const { result } = renderHook(() => ({ config: useNeapsConfig(), update: useUpdateConfig() }), { + wrapper, + }); + + act(() => { + result.current.update({ locale: "fr-FR" }); + }); + + expect(result.current.config.locale).toBe("fr-FR"); + }); + + test("persists settings to localStorage", () => { + const { result } = renderHook(() => ({ config: useNeapsConfig(), update: useUpdateConfig() }), { + wrapper, + }); + + act(() => { + result.current.update({ units: "meters" }); + }); + + const stored = JSON.parse(localStorage.getItem("neaps-settings") ?? "{}"); + expect(stored.units).toBe("meters"); + }); +}); diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts new file mode 100644 index 00000000..e494e0e2 --- /dev/null +++ b/packages/react/test/setup.ts @@ -0,0 +1,7 @@ +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + localStorage.removeItem("neaps-settings"); +}); diff --git a/packages/react/test/sun.test.ts b/packages/react/test/sun.test.ts new file mode 100644 index 00000000..42367032 --- /dev/null +++ b/packages/react/test/sun.test.ts @@ -0,0 +1,106 @@ +import { describe, test, expect } from "vitest"; +import { getDaylightMidpoints, getNightIntervals } from "../src/utils/sun.js"; + +// Boston, MA (latitude ~42.36, longitude ~-71.06) +const BOSTON = { lat: 42.36, lng: -71.06 }; + +// A known date range: Dec 17–19, 2025 (winter, short days) +const DEC_17 = Date.UTC(2025, 11, 17, 0, 0, 0); +const DEC_19 = Date.UTC(2025, 11, 19, 23, 59, 59); + +// A known date range: June 21–22, 2025 (summer solstice, long days) +const JUN_21 = Date.UTC(2025, 5, 21, 0, 0, 0); +const JUN_22 = Date.UTC(2025, 5, 22, 23, 59, 59); + +describe("getDaylightMidpoints", () => { + test("returns one midpoint per day", () => { + const midpoints = getDaylightMidpoints(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + // 3 days: Dec 17, 18, 19 + expect(midpoints.length).toBe(3); + }); + + test("midpoints are Date objects within the range", () => { + const midpoints = getDaylightMidpoints(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + for (const mp of midpoints) { + expect(mp).toBeInstanceOf(Date); + expect(mp.getTime()).toBeGreaterThanOrEqual(DEC_17); + expect(mp.getTime()).toBeLessThanOrEqual(DEC_19); + } + }); + + test("midpoints fall during daytime hours", () => { + const midpoints = getDaylightMidpoints(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + for (const mp of midpoints) { + const hour = mp.getUTCHours(); + // Boston is UTC-5, so solar noon ~17:00 UTC in winter; midpoint should be near that + expect(hour).toBeGreaterThanOrEqual(14); + expect(hour).toBeLessThanOrEqual(20); + } + }); + + test("returns empty array for zero-length range", () => { + const midpoints = getDaylightMidpoints(BOSTON.lat, BOSTON.lng, DEC_17, DEC_17); + // Might return 1 (the day start falls on) or 0, but should not throw + expect(midpoints.length).toBeLessThanOrEqual(1); + }); + + test("handles equatorial location", () => { + // Equator should have roughly equal day/night year-round + const equatorMids = getDaylightMidpoints(0, 0, DEC_17, DEC_19); + expect(equatorMids.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("getNightIntervals", () => { + test("returns night intervals for multi-day range", () => { + const intervals = getNightIntervals(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + // Should have at least 1 night intervals for a 3-day span + expect(intervals.length).toBeGreaterThanOrEqual(1); + }); + + test("each interval has start < end", () => { + const intervals = getNightIntervals(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + for (const interval of intervals) { + expect(interval.start).toBeLessThan(interval.end); + } + }); + + test("intervals are within the range (with padding)", () => { + const intervals = getNightIntervals(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + for (const interval of intervals) { + // The function pads by 1 day, but clamps to the range + expect(interval.start).toBeGreaterThanOrEqual(DEC_17); + expect(interval.end).toBeLessThanOrEqual(DEC_19); + } + }); + + test("returns fewer/shorter night intervals in summer", () => { + const winterNights = getNightIntervals(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + const summerNights = getNightIntervals(BOSTON.lat, BOSTON.lng, JUN_21, JUN_22); + + // Winter nights should be longer on average than summer nights + const avgWinterDuration = + winterNights.reduce((sum, n) => sum + (n.end - n.start), 0) / (winterNights.length || 1); + const avgSummerDuration = + summerNights.reduce((sum, n) => sum + (n.end - n.start), 0) / (summerNights.length || 1); + + if (winterNights.length > 0 && summerNights.length > 0) { + expect(avgWinterDuration).toBeGreaterThan(avgSummerDuration); + } + }); + + test("intervals represent nighttime (sunset to sunrise)", () => { + const intervals = getNightIntervals(BOSTON.lat, BOSTON.lng, DEC_17, DEC_19); + expect(intervals.length).toBeGreaterThan(0); + for (const interval of intervals) { + const durationHours = (interval.end - interval.start) / (60 * 60 * 1000); + // Each interval should be positive and not exceed a full day. + // Partial nights at range boundaries may be shorter. + expect(durationHours).toBeGreaterThan(0); + expect(durationHours).toBeLessThan(24); + } + // At least one full-length night interval should exist in a 3-day range + const fullNights = intervals.filter((i) => (i.end - i.start) / (60 * 60 * 1000) > 10); + expect(fullNights.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/packages/react/test/use-current-level.test.ts b/packages/react/test/use-current-level.test.ts new file mode 100644 index 00000000..a92d370d --- /dev/null +++ b/packages/react/test/use-current-level.test.ts @@ -0,0 +1,75 @@ +import { describe, test, expect } from "vitest"; +import { interpolateLevel } from "../src/hooks/use-current-level.js"; +import type { TimelineEntry } from "../src/types.js"; + +function entry(minutesFromEpoch: number, level: number): TimelineEntry { + return { time: new Date(minutesFromEpoch * 60000), level }; +} + +describe("interpolateLevel", () => { + test("returns null for empty timeline", () => { + expect(interpolateLevel([], 1000)).toBeNull(); + }); + + test("returns null when time is before all entries", () => { + const timeline = [entry(10, 1.0), entry(20, 2.0)]; + expect(interpolateLevel(timeline, 5 * 60000)).toBeNull(); + }); + + test("returns null when time is after all entries", () => { + const timeline = [entry(10, 1.0), entry(20, 2.0)]; + expect(interpolateLevel(timeline, 25 * 60000)).toBeNull(); + }); + + test("interpolates midpoint between two entries", () => { + const timeline = [entry(10, 1.0), entry(20, 3.0)]; + const result = interpolateLevel(timeline, 15 * 60000); + + expect(result).not.toBeNull(); + expect(result!.level).toBeCloseTo(2.0); + expect(result!.time.getTime()).toBe(15 * 60000); + }); + + test("interpolates at exact first entry time", () => { + const timeline = [entry(10, 1.0), entry(20, 3.0)]; + const result = interpolateLevel(timeline, 10 * 60000); + // lo=0 (time <= at), hi=1 (first time > at) + // ratio = 0, so level = 1.0 + expect(result).not.toBeNull(); + expect(result!.level).toBeCloseTo(1.0); + }); + + test("interpolates quarter way between entries", () => { + const timeline = [entry(0, 0), entry(100, 4.0)]; + const result = interpolateLevel(timeline, 25 * 60000); + + expect(result).not.toBeNull(); + expect(result!.level).toBeCloseTo(1.0); + }); + + test("works with multiple entries, picks correct pair", () => { + const timeline = [entry(0, 0), entry(10, 2.0), entry(20, 4.0), entry(30, 1.0)]; + // Between entry(20, 4.0) and entry(30, 1.0), midpoint + const result = interpolateLevel(timeline, 25 * 60000); + + expect(result).not.toBeNull(); + expect(result!.level).toBeCloseTo(2.5); + }); + + test("handles negative levels", () => { + const timeline = [entry(0, -2.0), entry(10, -4.0)]; + const result = interpolateLevel(timeline, 5 * 60000); + + expect(result).not.toBeNull(); + expect(result!.level).toBeCloseTo(-3.0); + }); + + test("returns correct time in result", () => { + const timeline = [entry(10, 1.0), entry(20, 2.0)]; + const queryTime = 17 * 60000; + const result = interpolateLevel(timeline, queryTime); + + expect(result).not.toBeNull(); + expect(result!.time.getTime()).toBe(queryTime); + }); +}); diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 00000000..9a22d040 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/packages/react/tsdown.config.ts b/packages/react/tsdown.config.ts new file mode 100644 index 00000000..deb3ab8f --- /dev/null +++ b/packages/react/tsdown.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["./src/index.ts"], + dts: true, + format: ["cjs", "esm"], + sourcemap: true, + target: "es2020", + platform: "browser", + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "maplibre-gl", + "react-map-gl", + "react-map-gl/maplibre", + "maplibre-gl/dist/maplibre-gl.css", + ], + copy: ["./src/styles.css"], +}); diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts new file mode 100644 index 00000000..833c5027 --- /dev/null +++ b/packages/react/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import { aliases } from "../../aliases.js"; + +export default defineConfig({ + resolve: { + alias: aliases("@neaps/react"), + }, +}); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..f3ea73c8 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineProject } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; +import { aliases } from "../../aliases.js"; + +export default defineProject({ + resolve: { + alias: aliases("@neaps/react"), + }, + test: { + browser: { + enabled: true, + provider: playwright({ contextOptions: { locale: "en-US" } }), + instances: [ + { + browser: "chromium", + headless: true, + }, + ], + }, + setupFiles: ["./test/setup.ts"], + globalSetup: ["./test/globalSetup.ts"], + }, +}); diff --git a/tsconfig.json b/tsconfig.json index d5566bd7..73e0b28c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "ESNext", "module": "ESNext", "lib": ["ESNext"], + "jsx": "react-jsx", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "esModuleInterop": true, diff --git a/vitest.config.ts b/vitest.config.ts index da31d38a..a06e1ab2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ setupFiles: [resolve(__dirname, "./test/setup.ts")], coverage: { include: ["packages/*/src/**"], + exclude: ["**/*.stories.tsx"], }, }, });