From 122b72f6b977af5cbe11721f505ee37a34976303 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 22 Feb 2026 18:33:21 -0500 Subject: [PATCH 01/48] Create @neaps/react package --- package.json | 3 +- packages/react/.storybook/main.ts | 29 ++ packages/react/.storybook/manager.ts | 4 + packages/react/.storybook/preview.tsx | 25 ++ packages/react/.storybook/storybook.css | 2 + packages/react/.storybook/theme.ts | 33 ++ packages/react/package.json | 80 ++++ packages/react/src/client.ts | 95 +++++ .../src/components/NearbyStations.stories.tsx | 76 ++++ .../react/src/components/NearbyStations.tsx | 128 +++++++ .../src/components/StationSearch.stories.tsx | 85 +++++ .../react/src/components/StationSearch.tsx | 240 ++++++++++++ .../src/components/StationsMap.stories.tsx | 95 +++++ packages/react/src/components/StationsMap.tsx | 351 +++++++++++++++++ .../src/components/TideGraph.stories.tsx | 109 ++++++ packages/react/src/components/TideGraph.tsx | 361 ++++++++++++++++++ .../src/components/TideStation.stories.tsx | 122 ++++++ packages/react/src/components/TideStation.tsx | 173 +++++++++ .../src/components/TideTable.stories.tsx | 96 +++++ packages/react/src/components/TideTable.tsx | 169 ++++++++ packages/react/src/hooks/use-dark-mode.ts | 38 ++ packages/react/src/hooks/use-extremes.ts | 38 ++ .../react/src/hooks/use-nearby-stations.ts | 20 + packages/react/src/hooks/use-station.ts | 13 + packages/react/src/hooks/use-stations.ts | 12 + packages/react/src/hooks/use-timeline.ts | 38 ++ packages/react/src/index.ts | 51 +++ packages/react/src/provider.tsx | 62 +++ packages/react/src/styles.css | 34 ++ packages/react/src/types.ts | 77 ++++ packages/react/src/utils/format.ts | 41 ++ packages/react/test/a11y.test.tsx | 99 +++++ packages/react/test/client.test.ts | 136 +++++++ .../test/components/NearbyStations.test.tsx | 53 +++ .../test/components/StationSearch.test.tsx | 122 ++++++ .../react/test/components/TideGraph.test.tsx | 48 +++ .../test/components/TideStation.test.tsx | 101 +++++ .../react/test/components/TideTable.test.tsx | 68 ++++ packages/react/test/format.test.ts | 71 ++++ packages/react/test/globalSetup.ts | 21 + packages/react/test/helpers.tsx | 23 ++ .../react/test/hooks/use-extremes.test.tsx | 62 +++ .../test/hooks/use-nearby-stations.test.tsx | 69 ++++ .../react/test/hooks/use-station.test.tsx | 50 +++ .../react/test/hooks/use-stations.test.tsx | 57 +++ .../react/test/hooks/use-timeline.test.tsx | 64 ++++ .../test/integration/NearbyStations.test.tsx | 85 +++++ .../test/integration/StationSearch.test.tsx | 108 ++++++ .../test/integration/TideStation.test.tsx | 71 ++++ packages/react/test/provider.test.tsx | 41 ++ packages/react/test/setup.ts | 9 + packages/react/test/vitest-axe.d.ts | 12 + packages/react/tsconfig.json | 8 + packages/react/tsdown.config.ts | 20 + packages/react/vitest.config.ts | 9 + tsconfig.json | 1 + 56 files changed, 4107 insertions(+), 1 deletion(-) create mode 100644 packages/react/.storybook/main.ts create mode 100644 packages/react/.storybook/manager.ts create mode 100644 packages/react/.storybook/preview.tsx create mode 100644 packages/react/.storybook/storybook.css create mode 100644 packages/react/.storybook/theme.ts create mode 100644 packages/react/package.json create mode 100644 packages/react/src/client.ts create mode 100644 packages/react/src/components/NearbyStations.stories.tsx create mode 100644 packages/react/src/components/NearbyStations.tsx create mode 100644 packages/react/src/components/StationSearch.stories.tsx create mode 100644 packages/react/src/components/StationSearch.tsx create mode 100644 packages/react/src/components/StationsMap.stories.tsx create mode 100644 packages/react/src/components/StationsMap.tsx create mode 100644 packages/react/src/components/TideGraph.stories.tsx create mode 100644 packages/react/src/components/TideGraph.tsx create mode 100644 packages/react/src/components/TideStation.stories.tsx create mode 100644 packages/react/src/components/TideStation.tsx create mode 100644 packages/react/src/components/TideTable.stories.tsx create mode 100644 packages/react/src/components/TideTable.tsx create mode 100644 packages/react/src/hooks/use-dark-mode.ts create mode 100644 packages/react/src/hooks/use-extremes.ts create mode 100644 packages/react/src/hooks/use-nearby-stations.ts create mode 100644 packages/react/src/hooks/use-station.ts create mode 100644 packages/react/src/hooks/use-stations.ts create mode 100644 packages/react/src/hooks/use-timeline.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/provider.tsx create mode 100644 packages/react/src/styles.css create mode 100644 packages/react/src/types.ts create mode 100644 packages/react/src/utils/format.ts create mode 100644 packages/react/test/a11y.test.tsx create mode 100644 packages/react/test/client.test.ts create mode 100644 packages/react/test/components/NearbyStations.test.tsx create mode 100644 packages/react/test/components/StationSearch.test.tsx create mode 100644 packages/react/test/components/TideGraph.test.tsx create mode 100644 packages/react/test/components/TideStation.test.tsx create mode 100644 packages/react/test/components/TideTable.test.tsx create mode 100644 packages/react/test/format.test.ts create mode 100644 packages/react/test/globalSetup.ts create mode 100644 packages/react/test/helpers.tsx create mode 100644 packages/react/test/hooks/use-extremes.test.tsx create mode 100644 packages/react/test/hooks/use-nearby-stations.test.tsx create mode 100644 packages/react/test/hooks/use-station.test.tsx create mode 100644 packages/react/test/hooks/use-stations.test.tsx create mode 100644 packages/react/test/hooks/use-timeline.test.tsx create mode 100644 packages/react/test/integration/NearbyStations.test.tsx create mode 100644 packages/react/test/integration/StationSearch.test.tsx create mode 100644 packages/react/test/integration/TideStation.test.tsx create mode 100644 packages/react/test/provider.test.tsx create mode 100644 packages/react/test/setup.ts create mode 100644 packages/react/test/vitest-axe.d.ts create mode 100644 packages/react/tsconfig.json create mode 100644 packages/react/tsdown.config.ts create mode 100644 packages/react/vitest.config.ts diff --git a/package.json b/package.json index 0279f7c7..7f327706 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "packages/tide-predictor", "packages/neaps", "packages/api", - "packages/cli" + "packages/cli", + "packages/react" ] } diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts new file mode 100644 index 00000000..f7ea4060 --- /dev/null +++ b/packages/react/.storybook/main.ts @@ -0,0 +1,29 @@ +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)"], + 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, () => { + console.log(`Neaps API listening on http://localhost:${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..ca35c801 --- /dev/null +++ b/packages/react/.storybook/manager.ts @@ -0,0 +1,4 @@ +import { addons } from "storybook/internal/manager-api"; +import theme from "./theme.js"; + +addons.setConfig({ theme }); diff --git a/packages/react/.storybook/preview.tsx b/packages/react/.storybook/preview.tsx new file mode 100644 index 00000000..e8337c13 --- /dev/null +++ b/packages/react/.storybook/preview.tsx @@ -0,0 +1,25 @@ +import type { Preview } from "@storybook/react"; +import { NeapsProvider } from "../src/provider.js"; +import "./storybook.css"; + +const API_URL = "http://localhost:6007"; + +const preview: Preview = { + decorators: [ + (Story) => ( + + + + ), + ], + 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..2ff2fc11 --- /dev/null +++ b/packages/react/.storybook/storybook.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; +@import "../src/styles.css"; diff --git a/packages/react/.storybook/theme.ts b/packages/react/.storybook/theme.ts new file mode 100644 index 00000000..0f98df2e --- /dev/null +++ b/packages/react/.storybook/theme.ts @@ -0,0 +1,33 @@ +import { create } from "storybook/internal/theming"; + +export default create({ + base: "light", + brandTitle: "Neaps", + brandUrl: "https://openwaters.io/tides/neaps", + + // Colors + colorPrimary: "#2563eb", + colorSecondary: "#2563eb", + + // UI + appBg: "#f8fafc", + appContentBg: "#ffffff", + appBorderColor: "#e2e8f0", + appBorderRadius: 8, + + // Text + textColor: "#0f172a", + textMutedColor: "#64748b", + textInverseColor: "#ffffff", + + // Toolbar + barTextColor: "#64748b", + barSelectedColor: "#2563eb", + barBg: "#ffffff", + + // Form + inputBg: "#ffffff", + inputBorder: "#e2e8f0", + inputTextColor: "#0f172a", + inputBorderRadius: 6, +}); diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 00000000..dc7f1cd4 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,80 @@ +{ + "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", + "prepack": "npm run build", + "storybook": "storybook dev -p 6006", + "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", + "chart.js": "^4.4.0", + "chartjs-adapter-date-fns": "^3.0.0", + "chartjs-plugin-annotation": "^3.1.0", + "react-chartjs-2": "^5.2.0" + }, + "devDependencies": { + "@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/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "canvas": "^3.2.1", + "jsdom": "^28.1.0", + "maplibre-gl": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-map-gl": "^7.0.0", + "resize-observer-polyfill": "^1.5.1", + "storybook": "^10.2.10", + "tailwindcss": "^4.2.0", + "vitest-axe": "^0.1.0" + } +} diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts new file mode 100644 index 00000000..13885cc9 --- /dev/null +++ b/packages/react/src/client.ts @@ -0,0 +1,95 @@ +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; +} + +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 url = new URL(path, base); + 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) }; +} + +export function fetchExtremes(baseUrl: string, params: LocationParams): Promise { + return fetchJSON(buildURL(baseUrl, "/tides/extremes", params)); +} + +export function fetchTimeline(baseUrl: string, params: LocationParams): Promise { + return fetchJSON(buildURL(baseUrl, "/tides/timeline", params)); +} + +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 function fetchStationExtremes( + baseUrl: string, + params: StationPredictionParams, +): Promise { + const { id, ...rest } = params; + const { source, stationId } = parseStationId(id); + return fetchJSON(buildURL(baseUrl, `/tides/stations/${source}/${stationId}/extremes`, rest)); +} + +export function fetchStationTimeline( + baseUrl: string, + params: StationPredictionParams, +): Promise { + const { id, ...rest } = params; + const { source, stationId } = parseStationId(id); + return fetchJSON(buildURL(baseUrl, `/tides/stations/${source}/${stationId}/timeline`, rest)); +} diff --git a/packages/react/src/components/NearbyStations.stories.tsx b/packages/react/src/components/NearbyStations.stories.tsx new file mode 100644 index 00000000..52e33565 --- /dev/null +++ b/packages/react/src/components/NearbyStations.stories.tsx @@ -0,0 +1,76 @@ +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 DarkMode: Story = { + args: { + stationId: "noaa/8443970", + onStationSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +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..8044a566 --- /dev/null +++ b/packages/react/src/components/NearbyStations.tsx @@ -0,0 +1,128 @@ +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; + className?: string; +}; + +export function NearbyStations(props: NearbyStationsProps) { + if (props.stationId) { + return ; + } + return ( + + ); +} + +function NearbyFromStation({ + stationId, + maxResults, + onStationSelect, + className, +}: { + stationId: string; + maxResults?: number; + onStationSelect?: (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, + maxResults = 5, + onStationSelect, + className, +}: { + latitude: number; + longitude: number; + maxResults?: number; + onStationSelect?: (station: StationSummary) => void; + className?: string; +}) { + const config = useNeapsConfig(); + const { + data: stations = [], + isLoading, + error, + } = useNearbyStations({ + latitude, + longitude, + maxResults, + }); + + if (isLoading) + return ( +
+ Loading nearby stations... +
+ ); + if (error) return
{error.message}
; + + return ( +
    + {stations.map((station) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/packages/react/src/components/StationSearch.stories.tsx b/packages/react/src/components/StationSearch.stories.tsx new file mode 100644 index 00000000..fdb8d9a3 --- /dev/null +++ b/packages/react/src/components/StationSearch.stories.tsx @@ -0,0 +1,85 @@ +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 DarkMode: Story = { + args: { + onSelect: (station) => console.log("Selected:", station), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +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..96783001 --- /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..a964fc0f --- /dev/null +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -0,0 +1,95 @@ +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, + 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", + center: [-71.05, 42.36], + zoom: 8, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const NoSearch: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + center: [-71.05, 42.36], + zoom: 8, + showSearch: false, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const HighZoom: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + center: [-71.05, 42.36], + zoom: 12, + onStationSelect: (station) => console.log("Selected:", station), + }, +}; + +export const DarkMode: Story = { + args: { + mapStyle: "https://demotiles.maplibre.org/style.json", + onStationSelect: (station) => console.log("Selected:", station), + }, + 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..d1368470 --- /dev/null +++ b/packages/react/src/components/StationsMap.tsx @@ -0,0 +1,351 @@ +import { useState, useCallback, useMemo } from "react"; +import { + Map, + Source, + Layer, + Popup, + type ViewStateChangeEvent, + type MapLayerMouseEvent, +} from "react-map-gl/maplibre"; +import "maplibre-gl/dist/maplibre-gl.css"; + +import { useStations } from "../hooks/use-stations.js"; +import { useExtremes } from "../hooks/use-extremes.js"; +import { useNeapsConfig } from "../provider.js"; +import { useDarkMode } from "../hooks/use-dark-mode.js"; +import { StationSearch } from "./StationSearch.js"; +import { formatLevel, formatTime } from "../utils/format.js"; +import type { StationSummary, Extreme } from "../types.js"; + +export interface StationsMapProps { + /** MapLibre style URL (required — e.g. MapTiler, Protomaps). */ + mapStyle: string; + /** Optional dark mode style URL. Switches automatically based on .dark class or prefers-color-scheme. */ + darkMapStyle?: string; + center?: [longitude: number, latitude: number]; + zoom?: number; + onStationSelect?: (station: StationSummary) => void; + onBoundsChange?: (bounds: { north: number; south: number; east: number; west: number }) => void; + showSearch?: boolean; + className?: string; +} + +function stationsToGeoJSON(stations: StationSummary[]): GeoJSON.FeatureCollection { + return { + type: "FeatureCollection", + features: stations.map((s) => ({ + type: "Feature" as const, + geometry: { type: "Point" as const, coordinates: [s.longitude, s.latitude] }, + properties: { id: s.id, name: s.name, region: s.region, country: s.country, type: s.type }, + })), + }; +} + +function getNextExtreme(extremes: Extreme[]): Extreme | null { + const now = new Date(); + return extremes.find((e) => new Date(e.time) > now) ?? null; +} + +function StationPreviewCard({ stationId }: { stationId: string }) { + const config = useNeapsConfig(); + const now = new Date(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const { data, isLoading } = useExtremes({ + id: stationId, + start: now.toISOString(), + end: end.toISOString(), + }); + + if (isLoading) { + return Loading...; + } + + if (!data) return null; + + const next = getNextExtreme(data.extremes); + return ( +
+ {next && ( +
+ Next {next.high ? "High" : "Low"}: + + {formatLevel(next.level, data.units ?? config.units)}{" "} + + at {formatTime(next.time, data.station?.timezone ?? "UTC")} + + +
+ )} +
+ ); +} + +export function StationsMap({ + mapStyle, + darkMapStyle, + center = [0, 30], + zoom = 3, + onStationSelect, + onBoundsChange, + showSearch = true, + className, +}: StationsMapProps) { + const [viewState, setViewState] = useState({ + longitude: center[0], + latitude: center[1], + zoom, + }); + const [selectedStation, setSelectedStation] = useState<{ + id: string; + name: string; + longitude: number; + latitude: number; + } | null>(null); + + const isDarkMode = useDarkMode(); + const effectiveMapStyle = isDarkMode && darkMapStyle ? darkMapStyle : mapStyle; + + const { data: stations = [] } = useStations(); + + const geojson = useMemo(() => stationsToGeoJSON(stations), [stations]); + + const handleMove = useCallback( + (e: ViewStateChangeEvent) => { + setViewState(e.viewState); + if (onBoundsChange) { + const bounds = e.target.getBounds(); + onBoundsChange({ + north: bounds.getNorth(), + south: bounds.getSouth(), + east: bounds.getEast(), + west: bounds.getWest(), + }); + } + }, + [onBoundsChange], + ); + + const handleSearchSelect = useCallback( + (station: StationSummary) => { + setViewState((prev) => ({ + ...prev, + longitude: station.longitude, + latitude: station.latitude, + zoom: 10, + })); + onStationSelect?.(station); + }, + [onStationSelect], + ); + + 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: StationSummary = { + id: props.id, + name: props.name, + latitude: coords[1], + longitude: coords[0], + region: props.region ?? "", + country: props.country ?? "", + continent: "", + timezone: "", + type: props.type ?? "reference", + }; + + if (viewState.zoom >= 10) { + setSelectedStation({ + id: props.id, + name: props.name, + longitude: coords[0], + latitude: coords[1], + }); + } + + onStationSelect?.(station); + } + }, + [onStationSelect, viewState.zoom], + ); + + const handleLocateMe = useCallback(() => { + navigator.geolocation.getCurrentPosition( + (pos) => { + setViewState((prev) => ({ + ...prev, + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + zoom: Math.max(prev.zoom, 10), + })); + }, + () => { + // Silently ignore geolocation errors + }, + ); + }, []); + + return ( +
+ + + {/* Clustered circles */} + + + {/* Cluster count labels */} + + + {/* Unclustered station points */} + + + {/* Station name labels at higher zoom */} + + + + {/* Live preview popup */} + {selectedStation && viewState.zoom >= 10 && ( + setSelectedStation(null)} + closeOnClick={false} + className="neaps-popup" + > +
+
+ {selectedStation.name} +
+ +
+
+ )} +
+ + {/* Search overlay */} + {showSearch && ( +
+ +
+ )} + + {/* Locate me button */} + {"geolocation" in navigator && ( + + )} +
+ ); +} diff --git a/packages/react/src/components/TideGraph.stories.tsx b/packages/react/src/components/TideGraph.stories.tsx new file mode 100644 index 00000000..85f1bad8 --- /dev/null +++ b/packages/react/src/components/TideGraph.stories.tsx @@ -0,0 +1,109 @@ +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, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + id: "noaa/8443970", + timeRange: "24h", + }, +}; + +export const ThreeDays: Story = { + args: { + id: "noaa/8443970", + timeRange: "3d", + }, +}; + +export const SevenDays: Story = { + args: { + id: "noaa/8443970", + timeRange: "7d", + }, +}; + +export const WithTimeRangeSelector: Story = { + args: { + id: "noaa/8443970", + showTimeRangeSelector: true, + }, +}; + +export const NarrowWidth: Story = { + args: { + id: "noaa/8443970", + timeRange: "24h", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const MediumWidth: Story = { + args: { + id: "noaa/8443970", + timeRange: "3d", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const DarkMode: Story = { + args: { + id: "noaa/8443970", + timeRange: "24h", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export const Loading: Story = { + args: { + id: "noaa/8443970", + timeRange: "24h", + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const Error: Story = { + args: { + id: "nonexistent/station", + timeRange: "24h", + }, +}; diff --git a/packages/react/src/components/TideGraph.tsx b/packages/react/src/components/TideGraph.tsx new file mode 100644 index 00000000..2c8c92b6 --- /dev/null +++ b/packages/react/src/components/TideGraph.tsx @@ -0,0 +1,361 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Line } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + TimeScale, + Filler, + Tooltip, + type ChartOptions, + type ChartData, +} from "chart.js"; +import annotationPlugin from "chartjs-plugin-annotation"; +import "chartjs-adapter-date-fns"; + +import { useTimeline, type UseTimelineParams } from "../hooks/use-timeline.js"; +import { useExtremes, type UseExtremesParams } from "../hooks/use-extremes.js"; +import { useNeapsConfig } from "../provider.js"; +import { formatLevel } from "../utils/format.js"; +import type { TimelineEntry, Extreme, Units } from "../types.js"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + TimeScale, + Filler, + Tooltip, + annotationPlugin, +); + +export type TimeRange = "24h" | "3d" | "7d"; + +export interface TideGraphDataProps { + timeline: TimelineEntry[]; + extremes?: Extreme[]; + timezone?: string; + units?: Units; + datum?: string; +} + +export interface TideGraphFetchProps { + id: string; + start?: Date; + end?: Date; + timeline?: undefined; +} + +export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { + timeRange?: TimeRange; + showTimeRangeSelector?: boolean; + className?: string; +}; + +function getTimeRangeDates(range: TimeRange, base: Date = new Date()): { start: Date; end: Date } { + const start = new Date(base); + start.setMinutes(0, 0, 0); + const end = new Date(start); + const days = range === "24h" ? 1 : range === "3d" ? 3 : 7; + end.setDate(end.getDate() + days); + return { start, end }; +} + +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 }; +} + +function getMaxTicksLimit(width: number): number { + if (width < 300) return 4; + if (width < 500) return 6; + if (width < 700) return 8; + return 12; +} + +function TideGraphChart({ + timeline, + extremes, + timezone, + units, + datum, + className, +}: { + timeline: TimelineEntry[]; + extremes: Extreme[]; + timezone: string; + units: Units; + datum?: string; + className?: string; +}) { + const { ref: containerRef, width: containerWidth } = useContainerWidth(); + const maxTicks = getMaxTicksLimit(containerWidth); + + const data: ChartData<"line"> = useMemo( + () => ({ + datasets: [ + { + label: "Water Level", + data: timeline.map((p) => ({ x: new Date(p.time).getTime(), y: p.level })), + borderColor: "var(--neaps-primary, #2563eb)", + backgroundColor: "color-mix(in srgb, var(--neaps-primary, #2563eb) 15%, transparent)", + fill: "origin", + tension: 0.4, + pointRadius: 0, + pointHitRadius: 8, + borderWidth: 2, + }, + { + label: "High/Low", + data: extremes.map((e) => ({ x: new Date(e.time).getTime(), y: e.level })), + borderColor: "transparent", + backgroundColor: extremes.map((e) => + e.high ? "var(--neaps-high, #3b82f6)" : "var(--neaps-low, #f59e0b)", + ), + pointRadius: 5, + pointHoverRadius: 7, + showLine: false, + }, + ], + }), + [timeline, extremes], + ); + + const options: ChartOptions<"line"> = useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: "index" as const, + }, + scales: { + x: { + type: "time" as const, + time: { + tooltipFormat: "PPp", + displayFormats: { + hour: "ha", + day: "MMM d", + }, + }, + adapters: { + date: { timeZone: timezone }, + }, + grid: { + color: "var(--neaps-border, #e2e8f0)", + }, + ticks: { + color: "var(--neaps-text, #0f172a)", + maxTicksLimit: maxTicks, + }, + }, + y: { + grid: { + color: "var(--neaps-border, #e2e8f0)", + }, + ticks: { + color: "var(--neaps-text, #0f172a)", + callback: (value) => formatLevel(value as number, units), + }, + ...(datum && { + title: { + display: true, + text: datum, + color: "var(--neaps-text-muted, #64748b)", + }, + }), + }, + }, + plugins: { + annotation: { + annotations: { + nowLine: { + type: "line" as const, + xMin: Date.now(), + xMax: Date.now(), + borderColor: "var(--neaps-text-muted, #64748b)", + borderWidth: 1, + borderDash: [4, 4], + label: { + display: true, + content: "Now", + position: "start" as const, + color: "var(--neaps-text-muted, #64748b)", + backgroundColor: "transparent", + font: { size: 10 }, + }, + }, + }, + }, + tooltip: { + callbacks: { + label: (ctx) => { + if (ctx.datasetIndex === 1) { + const extreme = extremes[ctx.dataIndex]; + return `${extreme.label}: ${formatLevel(extreme.level, units)}`; + } + return `${formatLevel(ctx.parsed.y ?? 0, units)}`; + }, + }, + }, + }, + }), + [timezone, units, datum, extremes, maxTicks], + ); + + return ( +
+ +
+ ); +} + +export function TideGraph(props: TideGraphProps) { + const config = useNeapsConfig(); + const [activeRange, setActiveRange] = useState(props.timeRange ?? "24h"); + + // Data-driven mode: timeline/extremes passed directly + if (props.timeline) { + return ( + + ); + } + + // Fetch mode: id provided + return ( + + ); +} + +function TideGraphFetcher({ + id, + start, + end, + activeRange, + setActiveRange, + showTimeRangeSelector, + className, +}: { + id: string; + start?: Date; + end?: Date; + activeRange: TimeRange; + setActiveRange: (r: TimeRange) => void; + showTimeRangeSelector?: boolean; + className?: string; +}) { + const config = useNeapsConfig(); + const rangeDates = useMemo(() => getTimeRangeDates(activeRange), [activeRange]); + const effectiveStart = start ?? rangeDates.start; + const effectiveEnd = end ?? rangeDates.end; + + const timelineParams: UseTimelineParams = { + id, + start: effectiveStart.toISOString(), + end: effectiveEnd.toISOString(), + }; + const extremesParams: UseExtremesParams = { + id, + start: effectiveStart.toISOString(), + end: effectiveEnd.toISOString(), + }; + + const timeline = useTimeline(timelineParams); + const extremes = useExtremes(extremesParams); + + if (timeline.isLoading || extremes.isLoading) { + return ( +
+ Loading tide data... +
+ ); + } + + if (timeline.error || extremes.error) { + return ( +
+ {(timeline.error ?? extremes.error)?.message} +
+ ); + } + + const station = timeline.data?.station ?? extremes.data?.station; + const timezone = station?.timezone ?? "UTC"; + + return ( +
+ {showTimeRangeSelector !== false && ( + + )} + +
+ ); +} + +function TimeRangeSelector({ + active, + onChange, +}: { + active: TimeRange; + onChange: (r: TimeRange) => void; +}) { + const ranges: TimeRange[] = ["24h", "3d", "7d"]; + return ( +
+ {ranges.map((r) => ( + + ))} +
+ ); +} diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx new file mode 100644 index 00000000..7b625a0e --- /dev/null +++ b/packages/react/src/components/TideStation.stories.tsx @@ -0,0 +1,122 @@ +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 WithTable: Story = { + args: { + id: "noaa/8443970", + showTable: true, + }, +}; + +export const TableOnly: Story = { + args: { + id: "noaa/8443970", + showGraph: false, + showTable: true, + }, +}; + +export const ThreeDayRange: Story = { + args: { + id: "noaa/8443970", + timeRange: "3d", + showTable: true, + }, +}; + +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 DarkMode: 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..978c9b27 --- /dev/null +++ b/packages/react/src/components/TideStation.tsx @@ -0,0 +1,173 @@ +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 { formatLevel } from "../utils/format.js"; +import { TideGraph, type TimeRange } from "./TideGraph.js"; +import { TideTable } from "./TideTable.js"; +import type { Extreme, TimelineEntry, Units } from "../types.js"; + +export interface TideStationProps { + id: string; + showGraph?: boolean; + showTable?: boolean; + timeRange?: TimeRange | { start: Date; end: Date }; + className?: string; +} + +function getDateRange(timeRange: TimeRange | { start: Date; end: Date }): { + start: Date; + end: Date; +} { + if (typeof timeRange === "object") return timeRange; + const start = new Date(); + start.setMinutes(0, 0, 0); + const end = new Date(start); + const days = timeRange === "24h" ? 1 : timeRange === "3d" ? 3 : 7; + end.setDate(end.getDate() + days); + return { start, end }; +} + +function getCurrentLevel(timeline: TimelineEntry[]): number | null { + const now = Date.now(); + for (let i = 1; i < timeline.length; i++) { + const t1 = new Date(timeline[i - 1].time).getTime(); + const t2 = new Date(timeline[i].time).getTime(); + if (now >= t1 && now <= t2) { + const ratio = (now - t1) / (t2 - t1); + return timeline[i - 1].level + ratio * (timeline[i].level - timeline[i - 1].level); + } + } + return null; +} + +function getNextExtreme(extremes: Extreme[]): Extreme | null { + const now = new Date(); + return extremes.find((e) => new Date(e.time) > now) ?? null; +} + +function isTideRising(extremes: Extreme[]): boolean | null { + const next = getNextExtreme(extremes); + if (!next) return null; + return next.high; +} + +function formatTimeUntil(isoTime: string): string { + const diff = new Date(isoTime).getTime() - Date.now(); + if (diff <= 0) return "now"; + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +export function TideStation({ + id, + showGraph = true, + showTable = false, + timeRange = "24h", + className, +}: TideStationProps) { + const config = useNeapsConfig(); + const { start, end } = useMemo(() => getDateRange(timeRange), [timeRange]); + + const station = useStation(id); + const timeline = useTimeline({ id, start: start.toISOString(), end: end.toISOString() }); + const extremes = useExtremes({ id, start: start.toISOString(), end: end.toISOString() }); + + 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 timelineData = timeline.data?.timeline ?? []; + const extremesData = extremes.data?.extremes ?? []; + const currentLevel = getCurrentLevel(timelineData); + const nextExtreme = getNextExtreme(extremesData); + const rising = isTideRising(extremesData); + + return ( +
+
+
+

{s.name}

+
+ + {[s.region, s.country].filter(Boolean).join(", ")} + +
+ +
+ {currentLevel !== null && ( +
+ + {formatLevel(currentLevel, units)} + + {rising !== null && ( + + {rising ? "\u2191" : "\u2193"} + + )} +
+ )} + {nextExtreme && ( +
+ + {nextExtreme.high ? "High" : "Low"} in {formatTimeUntil(nextExtreme.time)} + + + {formatLevel(nextExtreme.level, units)} + +
+ )} +
+ + {(showGraph || showTable) && ( +
+ {showGraph && ( +
+ +
+ )} + {showTable && ( +
+ +
+ )} +
+ )} +
+ ); +} diff --git a/packages/react/src/components/TideTable.stories.tsx b/packages/react/src/components/TideTable.stories.tsx new file mode 100644 index 00000000..9e2b41bc --- /dev/null +++ b/packages/react/src/components/TideTable.stories.tsx @@ -0,0 +1,96 @@ +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 DarkMode: 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..8df8633a --- /dev/null +++ b/packages/react/src/components/TideTable.tsx @@ -0,0 +1,169 @@ +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, + className, +}: { + extremes: Extreme[]; + timezone: string; + units: Units; + 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), extremes: [] }); + } + groups.get(key)!.extremes.push(extreme); + } + return Array.from(groups.values()); + }, [extremes, timezone]); + + const now = new Date(); + let foundNext = false; + + return ( +
+ + + + + + + + + + + {grouped.map((group) => + group.extremes.map((extreme, i) => { + const isNext = !foundNext && new Date(extreme.time) > now; + if (isNext) foundNext = true; + + return ( + + {i === 0 ? ( + + ) : null} + + + + + ); + }), + )} + +
+ Date + + Time + + Level + + Type +
+ {group.label} + + {formatTime(extreme.time, timezone)} + + {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/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-extremes.ts b/packages/react/src/hooks/use-extremes.ts new file mode 100644 index 00000000..301438d9 --- /dev/null +++ b/packages/react/src/hooks/use-extremes.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { + fetchExtremes, + fetchStationExtremes, + type LocationParams, + type StationPredictionParams, + type PredictionParams, +} from "../client.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: ["neaps", "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..756a506c --- /dev/null +++ b/packages/react/src/hooks/use-nearby-stations.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStations } from "../client.js"; + +export interface UseNearbyStationsParams { + latitude: number; + longitude: number; + maxResults?: number; + maxDistance?: number; +} + +export function useNearbyStations(params: UseNearbyStationsParams | undefined) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: ["neaps", "nearby-stations", 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..ee3eb140 --- /dev/null +++ b/packages/react/src/hooks/use-station.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStation } from "../client.js"; + +export function useStation(id: string | undefined) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: ["neaps", "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..225b4718 --- /dev/null +++ b/packages/react/src/hooks/use-stations.ts @@ -0,0 +1,12 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { fetchStations, type StationsSearchParams } from "../client.js"; + +export function useStations(params: StationsSearchParams = {}) { + const { baseUrl } = useNeapsConfig(); + + return useQuery({ + queryKey: ["neaps", "stations", params], + queryFn: () => fetchStations(baseUrl, params), + }); +} diff --git a/packages/react/src/hooks/use-timeline.ts b/packages/react/src/hooks/use-timeline.ts new file mode 100644 index 00000000..9249a28e --- /dev/null +++ b/packages/react/src/hooks/use-timeline.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNeapsConfig } from "../provider.js"; +import { + fetchTimeline, + fetchStationTimeline, + type LocationParams, + type StationPredictionParams, + type PredictionParams, +} from "../client.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: ["neaps", "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..9594ac23 --- /dev/null +++ b/packages/react/src/index.ts @@ -0,0 +1,51 @@ +// Provider +export { NeapsProvider, useNeapsConfig } from "./provider.js"; +export type { NeapsProviderProps, NeapsConfig } from "./provider.js"; + +// Hooks +export { useStation } from "./hooks/use-station.js"; +export { useStations } from "./hooks/use-stations.js"; +export { useExtremes } from "./hooks/use-extremes.js"; +export type { UseExtremesParams } from "./hooks/use-extremes.js"; +export { useTimeline } from "./hooks/use-timeline.js"; +export type { UseTimelineParams } from "./hooks/use-timeline.js"; +export { useNearbyStations } from "./hooks/use-nearby-stations.js"; +export type { UseNearbyStationsParams } from "./hooks/use-nearby-stations.js"; + +// Components +export { TideStation } from "./components/TideStation.js"; +export type { TideStationProps } from "./components/TideStation.js"; +export { TideGraph } from "./components/TideGraph.js"; +export type { TideGraphProps, TimeRange } from "./components/TideGraph.js"; +export { TideTable } from "./components/TideTable.js"; +export type { TideTableProps } from "./components/TideTable.js"; +export { StationSearch } from "./components/StationSearch.js"; +export type { StationSearchProps } from "./components/StationSearch.js"; +export { NearbyStations } from "./components/NearbyStations.js"; +export type { NearbyStationsProps } from "./components/NearbyStations.js"; +export { StationsMap } from "./components/StationsMap.js"; +export type { StationsMapProps } from "./components/StationsMap.js"; + +// Client +export { + fetchExtremes, + fetchTimeline, + fetchStation, + fetchStations, + fetchStationExtremes, + fetchStationTimeline, +} from "./client.js"; + +// Types +export type { + Units, + Station, + StationSummary, + Extreme, + TimelineEntry, + ExtremesResponse, + TimelineResponse, +} from "./types.js"; + +// Utilities +export { formatLevel, formatTime, formatDate, formatDistance } from "./utils/format.js"; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx new file mode 100644 index 00000000..c5be4d0e --- /dev/null +++ b/packages/react/src/provider.tsx @@ -0,0 +1,62 @@ +import { createContext, useContext, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import type { Units } from "./types.js"; + +export interface NeapsConfig { + baseUrl: string; + units: Units; + datum?: string; +} + +const NeapsContext = createContext(null); + +let defaultQueryClient: QueryClient | null = null; + +function getDefaultQueryClient(): QueryClient { + if (!defaultQueryClient) { + defaultQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }); + } + return defaultQueryClient; +} + +export interface NeapsProviderProps { + baseUrl: string; + units?: Units; + datum?: string; + queryClient?: QueryClient; + children: ReactNode; +} + +export function NeapsProvider({ + baseUrl, + units = "meters", + datum, + queryClient, + children, +}: NeapsProviderProps) { + const config: NeapsConfig = { baseUrl, units, datum }; + + return ( + + + {children} + + + ); +} + +export function useNeapsConfig(): NeapsConfig { + const config = useContext(NeapsContext); + if (!config) { + throw new Error("useNeapsConfig must be used within a "); + } + return config; +} diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css new file mode 100644 index 00000000..57ae1470 --- /dev/null +++ b/packages/react/src/styles.css @@ -0,0 +1,34 @@ +/* Neaps React Component Library — Theme Variables */ + +:root { + --neaps-primary: #2563eb; + --neaps-high: #3b82f6; + --neaps-low: #f59e0b; + --neaps-bg: #ffffff; + --neaps-bg-subtle: #f8fafc; + --neaps-text: #0f172a; + --neaps-text-muted: #64748b; + --neaps-border: #e2e8f0; +} + +.dark { + --neaps-primary: #60a5fa; + --neaps-high: #60a5fa; + --neaps-low: #fbbf24; + --neaps-bg: #0f172a; + --neaps-bg-subtle: #1e293b; + --neaps-text: #f1f5f9; + --neaps-text-muted: #94a3b8; + --neaps-border: #334155; +} + +/* 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..c5405953 --- /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: string; + level: number; + high: boolean; + low: boolean; + label: string; +} + +export interface TimelineEntry { + time: string; + 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/format.ts b/packages/react/src/utils/format.ts new file mode 100644 index 00000000..51889eb4 --- /dev/null +++ b/packages/react/src/utils/format.ts @@ -0,0 +1,41 @@ +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 string in the station's timezone. */ +export function formatTime(isoTime: string, timezone: string): string { + return new Date(isoTime).toLocaleTimeString("en-US", { + timeZone: timezone, + hour: "numeric", + minute: "2-digit", + }); +} + +/** Format a date string in the station's timezone. */ +export function formatDate(isoTime: string, timezone: string): string { + return new Date(isoTime).toLocaleDateString("en-US", { + 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; + return miles < 0.1 ? `${Math.round(meters * 3.2808399)} ft` : `${miles.toFixed(1)} mi`; + } + return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; +} + +/** Get a date key (YYYY-MM-DD) in the station's timezone. */ +export function getDateKey(isoTime: string, timezone: string): string { + return new Date(isoTime).toLocaleDateString("en-CA", { timeZone: timezone }); +} diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx new file mode 100644 index 00000000..c6b217f9 --- /dev/null +++ b/packages/react/test/a11y.test.tsx @@ -0,0 +1,99 @@ +import { describe, test, expect } from "vitest"; +import { render, within, waitFor } from "@testing-library/react"; +import { configureAxe } from "vitest-axe"; +import * as axeMatchers from "vitest-axe/matchers"; +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 { TideGraph } from "../src/components/TideGraph.js"; +import { createTestWrapper } from "./helpers.js"; + +const axe = configureAxe({ + globalOptions: { + checks: [ + { + id: "color-contrast", + enabled: false, + }, + ], + }, +}); + +expect.extend(axeMatchers); + +const STATION_ID = "noaa/8443970"; + +describe("accessibility", () => { + test("TideTable with data has no violations", async () => { + const extremes = [ + { time: "2025-12-17T04:30:00Z", level: 1.5, high: true, low: false, label: "High" }, + { time: "2025-12-17T10:45:00Z", level: 0.2, high: false, low: true, label: "Low" }, + { time: "2025-12-17T16:00:00Z", level: 1.4, high: true, low: false, label: "High" }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test("StationSearch has no violations", async () => { + const { container } = render( {}} />, { + wrapper: createTestWrapper(), + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + 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 }, + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test("TideStation has no violations after loading", async () => { + const { container } = render(, { wrapper: createTestWrapper() }); + const view = within(container); + + await waitFor( + () => { + expect(view.queryByText("Loading...")).toBeNull(); + }, + { timeout: 10000 }, + ); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + test("TideGraph with data has no violations", async () => { + const timeline = [ + { time: "2025-12-17T00:00:00Z", level: 0.5 }, + { time: "2025-12-17T06:00:00Z", level: 1.5 }, + { time: "2025-12-17T12:00:00Z", level: 0.3 }, + { time: "2025-12-17T18:00:00Z", level: 1.4 }, + ]; + + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/packages/react/test/client.test.ts b/packages/react/test/client.test.ts new file mode 100644 index 00000000..7e6244fb --- /dev/null +++ b/packages/react/test/client.test.ts @@ -0,0 +1,136 @@ +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"; + +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("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/StationSearch.test.tsx b/packages/react/test/components/StationSearch.test.tsx new file mode 100644 index 00000000..0ff7c6e6 --- /dev/null +++ b/packages/react/test/components/StationSearch.test.tsx @@ -0,0 +1,122 @@ +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"; + +// Node 22's global localStorage doesn't implement Web Storage API +const store = new Map(); +Object.defineProperty(window, "localStorage", { + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (i: number) => [...store.keys()][i] ?? null, + }, + writable: true, + configurable: true, +}); + +describe("StationSearch", () => { + beforeEach(() => { + store.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 + store.set( + "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 + store.set( + "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(store.get("neaps-recent-searches") ?? "[]"); + expect(recent.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx new file mode 100644 index 00000000..3757ba0e --- /dev/null +++ b/packages/react/test/components/TideGraph.test.tsx @@ -0,0 +1,48 @@ +import { describe, test, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { TideGraph } from "../../src/components/TideGraph.js"; +import { createTestWrapper } from "../helpers.js"; + +const timeline = [ + { time: "2025-12-17T00:00:00Z", level: 0.5 }, + { time: "2025-12-17T03:00:00Z", level: 1.2 }, + { time: "2025-12-17T06:00:00Z", level: 1.5 }, + { time: "2025-12-17T09:00:00Z", level: 0.8 }, + { time: "2025-12-17T12:00:00Z", level: 0.3 }, + { time: "2025-12-17T15:00:00Z", level: 0.9 }, + { time: "2025-12-17T18:00:00Z", level: 1.4 }, + { time: "2025-12-17T21:00:00Z", level: 0.7 }, +]; + +const extremes = [ + { time: "2025-12-17T06:00:00Z", level: 1.5, high: true, low: false, label: "High" }, + { time: "2025-12-17T12:00:00Z", level: 0.3, high: false, low: true, label: "Low" }, +]; + +describe("TideGraph", () => { + test("renders a canvas element in data-driven mode", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector("canvas")).not.toBeNull(); + }); + + test("renders with empty extremes", () => { + const { container } = render(, { + wrapper: createTestWrapper(), + }); + + expect(container.querySelector("canvas")).not.toBeNull(); + }); + + test("applies className", () => { + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); + + expect(container.querySelector(".my-graph")).not.toBeNull(); + }); +}); diff --git a/packages/react/test/components/TideStation.test.tsx b/packages/react/test/components/TideStation.test.tsx new file mode 100644 index 00000000..ec721401 --- /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: 3 }); + 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 by default, no table", 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")).toBeNull(); + }); + + 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/TideTable.test.tsx b/packages/react/test/components/TideTable.test.tsx new file mode 100644 index 00000000..57db1933 --- /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: "2025-12-17T04:30:00Z", level: 1.5, high: true, low: false, label: "High" }, + { time: "2025-12-17T10:45:00Z", level: 0.2, high: false, low: true, label: "Low" }, + { time: "2025-12-17T16:00:00Z", level: 1.4, high: true, low: false, label: "High" }, + { time: "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/format.test.ts b/packages/react/test/format.test.ts new file mode 100644 index 00000000..3f01a57d --- /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("2025-12-17T10:23:00Z", "America/New_York"); + expect(result).toBe("5:23 AM"); + }); + + test("UTC timezone", () => { + const result = formatTime("2025-12-17T10:23:00Z", "UTC"); + expect(result).toBe("10:23 AM"); + }); +}); + +describe("formatDate", () => { + test("formats date with weekday, month, day", () => { + const result = formatDate("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("2025-12-17T02:00:00Z", "America/New_York")).toBe("2025-12-16"); + expect(getDateKey("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..24d6f122 --- /dev/null +++ b/packages/react/test/globalSetup.ts @@ -0,0 +1,21 @@ +import { createApp } from "@neaps/api"; +import type { GlobalSetupContext } from "vitest/node"; + +export default function setup({ provide }: GlobalSetupContext) { + 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..b832cd75 --- /dev/null +++ b/packages/react/test/helpers.tsx @@ -0,0 +1,23 @@ +import { inject } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { NeapsProvider } from "../src/provider.js"; +import type { ReactNode } from "react"; + +export function createTestWrapper({ baseUrl }: { baseUrl?: string } = {}) { + const url = baseUrl ?? inject("apiBaseUrl"); + 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..3404832e --- /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).toBeTypeOf("string"); + 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..f7b4d9c6 --- /dev/null +++ b/packages/react/test/integration/StationSearch.test.tsx @@ -0,0 +1,108 @@ +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"; + +// Node 22's global localStorage doesn't implement Web Storage API +const store = new Map(); +Object.defineProperty(window, "localStorage", { + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => { + store.delete(key); + }, + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (i: number) => [...store.keys()][i] ?? null, + }, + writable: true, + configurable: true, +}); + +describe("StationSearch integration", () => { + beforeEach(() => store.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..1556ebf9 --- /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: 3 })).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: 3 })).toBeNull(); + }); +}); diff --git a/packages/react/test/provider.test.tsx b/packages/react/test/provider.test.tsx new file mode 100644 index 00000000..2f19ff26 --- /dev/null +++ b/packages/react/test/provider.test.tsx @@ -0,0 +1,41 @@ +import { describe, test, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { NeapsProvider, useNeapsConfig } from "../src/provider.js"; +import type { ReactNode } from "react"; + +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", + }); + }); + + test("defaults units to meters", () => { + const minimalWrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useNeapsConfig(), { wrapper: minimalWrapper }); + + expect(result.current.units).toBe("meters"); + expect(result.current.datum).toBeUndefined(); + }); + + test("throws when used outside provider", () => { + expect(() => { + renderHook(() => useNeapsConfig()); + }).toThrow("useNeapsConfig must be used within a "); + }); +}); diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts new file mode 100644 index 00000000..9a34bb98 --- /dev/null +++ b/packages/react/test/setup.ts @@ -0,0 +1,9 @@ +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; +import ResizeObserver from "resize-observer-polyfill"; + +global.ResizeObserver = ResizeObserver; + +afterEach(() => { + cleanup(); +}); diff --git a/packages/react/test/vitest-axe.d.ts b/packages/react/test/vitest-axe.d.ts new file mode 100644 index 00000000..534604f3 --- /dev/null +++ b/packages/react/test/vitest-axe.d.ts @@ -0,0 +1,12 @@ +import "vitest"; + +interface AxeMatchers { + toHaveNoViolations(): void; +} + +declare module "vitest" { + // eslint-disable-next-line + interface Assertion extends AxeMatchers {} + // eslint-disable-next-line + interface AsymmetricMatchersContaining extends AxeMatchers {} +} 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..7633e0c7 --- /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: "neutral", + 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/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..08046e23 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + environment: "jsdom", + 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, From 6b3ebebb66f39764f2b55e855db41efec3c8b5cc Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 23 Feb 2026 14:05:34 -0500 Subject: [PATCH 02/48] Extract TideConditions component --- packages/react/README.md | 196 ++++++++++++++++++ .../react/src/components/TideConditions.tsx | 77 +++++++ packages/react/src/components/TideStation.tsx | 84 ++------ packages/react/src/index.ts | 2 + .../test/components/TideStation.test.tsx | 4 +- packages/react/test/globalSetup.ts | 4 +- 6 files changed, 291 insertions(+), 76 deletions(-) create mode 100644 packages/react/README.md create mode 100644 packages/react/src/components/TideConditions.tsx diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 00000000..1dc62fb8 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,196 @@ +# @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 when a parent element has the `dark` class or the user's system preference is `prefers-color-scheme: dark`. Override dark mode colors: + +```css +.dark { + --neaps-primary: #60a5fa; + --neaps-bg: #0f172a; + --neaps-text: #f1f5f9; + /* ... */ +} +``` + +## License + +MIT diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx new file mode 100644 index 00000000..f1c85a5b --- /dev/null +++ b/packages/react/src/components/TideConditions.tsx @@ -0,0 +1,77 @@ +import { formatLevel } from "../utils/format.js"; +import type { Extreme, TimelineEntry, Units } from "../types.js"; + +export interface TideConditionsProps { + timeline: TimelineEntry[]; + extremes: Extreme[]; + units: Units; + className?: string; +} + +function getCurrentLevel(timeline: TimelineEntry[]): number | null { + const now = Date.now(); + for (let i = 1; i < timeline.length; i++) { + const t1 = new Date(timeline[i - 1].time).getTime(); + const t2 = new Date(timeline[i].time).getTime(); + if (now >= t1 && now <= t2) { + const ratio = (now - t1) / (t2 - t1); + return timeline[i - 1].level + ratio * (timeline[i].level - timeline[i - 1].level); + } + } + return null; +} + +function getNextExtreme(extremes: Extreme[]): Extreme | null { + const now = new Date(); + return extremes.find((e) => new Date(e.time) > now) ?? null; +} + +function isTideRising(extremes: Extreme[]): boolean | null { + const next = getNextExtreme(extremes); + if (!next) return null; + return next.high; +} + +function formatTimeUntil(isoTime: string): string { + const diff = new Date(isoTime).getTime() - Date.now(); + if (diff <= 0) return "now"; + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +export function TideConditions({ timeline, extremes, units, className }: TideConditionsProps) { + const currentLevel = getCurrentLevel(timeline); + const nextExtreme = getNextExtreme(extremes); + const rising = isTideRising(extremes); + + return ( +
+ {currentLevel !== null && ( +
+ + {formatLevel(currentLevel, units)} + + {rising !== null && ( + + {rising ? "\u2191" : "\u2193"} + + )} +
+ )} + {nextExtreme && ( +
+ + {nextExtreme.high ? "High" : "Low"}: + {formatLevel(nextExtreme.level, units)} + in {formatTimeUntil(nextExtreme.time)} + +
+ )} +
+ ); +} diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index 978c9b27..e18e9b6e 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -4,10 +4,10 @@ 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 { formatLevel } from "../utils/format.js"; +import { TideConditions } from "./TideConditions.js"; import { TideGraph, type TimeRange } from "./TideGraph.js"; import { TideTable } from "./TideTable.js"; -import type { Extreme, TimelineEntry, Units } from "../types.js"; +import type { Units } from "../types.js"; export interface TideStationProps { id: string; @@ -30,43 +30,11 @@ function getDateRange(timeRange: TimeRange | { start: Date; end: Date }): { return { start, end }; } -function getCurrentLevel(timeline: TimelineEntry[]): number | null { - const now = Date.now(); - for (let i = 1; i < timeline.length; i++) { - const t1 = new Date(timeline[i - 1].time).getTime(); - const t2 = new Date(timeline[i].time).getTime(); - if (now >= t1 && now <= t2) { - const ratio = (now - t1) / (t2 - t1); - return timeline[i - 1].level + ratio * (timeline[i].level - timeline[i - 1].level); - } - } - return null; -} - -function getNextExtreme(extremes: Extreme[]): Extreme | null { - const now = new Date(); - return extremes.find((e) => new Date(e.time) > now) ?? null; -} - -function isTideRising(extremes: Extreme[]): boolean | null { - const next = getNextExtreme(extremes); - if (!next) return null; - return next.high; -} - -function formatTimeUntil(isoTime: string): string { - const diff = new Date(isoTime).getTime() - Date.now(); - if (diff <= 0) return "now"; - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} export function TideStation({ id, showGraph = true, - showTable = false, + showTable = true, timeRange = "24h", className, }: TideStationProps) { @@ -102,50 +70,22 @@ export function TideStation({ const units: Units = timeline.data?.units ?? config.units; const timelineData = timeline.data?.timeline ?? []; const extremesData = extremes.data?.extremes ?? []; - const currentLevel = getCurrentLevel(timelineData); - const nextExtreme = getNextExtreme(extremesData); - const rising = isTideRising(extremesData); - return (
-
-
-

{s.name}

+
+
+
+

{s.name}

+
+ + {[s.region, s.country].filter(Boolean).join(", ")} +
- - {[s.region, s.country].filter(Boolean).join(", ")} - +
-
- {currentLevel !== null && ( -
- - {formatLevel(currentLevel, units)} - - {rising !== null && ( - - {rising ? "\u2191" : "\u2193"} - - )} -
- )} - {nextExtreme && ( -
- - {nextExtreme.high ? "High" : "Low"} in {formatTimeUntil(nextExtreme.time)} - - - {formatLevel(nextExtreme.level, units)} - -
- )} -
{(showGraph || showTable) && (
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9594ac23..455b2fc5 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -15,6 +15,8 @@ export type { UseNearbyStationsParams } from "./hooks/use-nearby-stations.js"; // Components export { TideStation } from "./components/TideStation.js"; export type { TideStationProps } from "./components/TideStation.js"; +export { TideConditions } from "./components/TideConditions.js"; +export type { TideConditionsProps } from "./components/TideConditions.js"; export { TideGraph } from "./components/TideGraph.js"; export type { TideGraphProps, TimeRange } from "./components/TideGraph.js"; export { TideTable } from "./components/TideTable.js"; diff --git a/packages/react/test/components/TideStation.test.tsx b/packages/react/test/components/TideStation.test.tsx index ec721401..4a02eda4 100644 --- a/packages/react/test/components/TideStation.test.tsx +++ b/packages/react/test/components/TideStation.test.tsx @@ -50,7 +50,7 @@ describe("TideStation", () => { expect(rising ?? falling).toBeDefined(); }); - test("renders graph by default, no table", async () => { + test("renders graph and table by default", async () => { const { container } = render(, { wrapper: createTestWrapper(), }); @@ -64,7 +64,7 @@ describe("TideStation", () => { ); expect(container.querySelector("canvas")).toBeDefined(); - expect(view.queryByRole("table")).toBeNull(); + expect(view.queryByRole("table")).toBeDefined(); }); test("renders table when showTable is true", async () => { diff --git a/packages/react/test/globalSetup.ts b/packages/react/test/globalSetup.ts index 24d6f122..3303c25c 100644 --- a/packages/react/test/globalSetup.ts +++ b/packages/react/test/globalSetup.ts @@ -1,7 +1,7 @@ import { createApp } from "@neaps/api"; -import type { GlobalSetupContext } from "vitest/node"; +import type { TestProject } from "vitest/node"; -export default function setup({ provide }: GlobalSetupContext) { +export default function setup({ provide }: TestProject) { const app = createApp(); const server = app.listen(0); const address = server.address(); From 5702b6a89a34b445ec14cf08f393bb95baaed9f0 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 23 Feb 2026 14:23:45 -0500 Subject: [PATCH 03/48] Debounce Map --- packages/react/src/components/StationsMap.tsx | 27 ++++++++++++------- .../react/src/hooks/use-debounced-callback.ts | 20 ++++++++++++++ packages/react/src/hooks/use-stations.ts | 8 ++++-- 3 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 packages/react/src/hooks/use-debounced-callback.ts diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index d1368470..fa153207 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -8,8 +8,10 @@ import { type MapLayerMouseEvent, } from "react-map-gl/maplibre"; import "maplibre-gl/dist/maplibre-gl.css"; +import { keepPreviousData } from "@tanstack/react-query"; import { useStations } from "../hooks/use-stations.js"; +import { useDebouncedCallback } from "../hooks/use-debounced-callback.js"; import { useExtremes } from "../hooks/use-extremes.js"; import { useNeapsConfig } from "../provider.js"; import { useDarkMode } from "../hooks/use-dark-mode.js"; @@ -95,6 +97,10 @@ export function StationsMap({ latitude: center[1], zoom, }); + const [bbox, setBbox] = useState< + [min: [number, number], max: [number, number]] | null + >(null); + const debouncedSetBbox = useDebouncedCallback(setBbox, 200); const [selectedStation, setSelectedStation] = useState<{ id: string; name: string; @@ -105,22 +111,23 @@ export function StationsMap({ const isDarkMode = useDarkMode(); const effectiveMapStyle = isDarkMode && darkMapStyle ? darkMapStyle : mapStyle; - const { data: stations = [] } = useStations(); + const { data: stations = [] } = useStations(bbox ? { bbox } : {}, { + placeholderData: keepPreviousData, + }); const geojson = useMemo(() => stationsToGeoJSON(stations), [stations]); const handleMove = useCallback( (e: ViewStateChangeEvent) => { setViewState(e.viewState); - if (onBoundsChange) { - const bounds = e.target.getBounds(); - onBoundsChange({ - north: bounds.getNorth(), - south: bounds.getSouth(), - east: bounds.getEast(), - west: bounds.getWest(), - }); - } + const mapBounds = e.target.getBounds(); + debouncedSetBbox(mapBounds.toArray() as [[number, number], [number, number]]); + onBoundsChange?.({ + north: mapBounds.getNorth(), + south: mapBounds.getSouth(), + east: mapBounds.getEast(), + west: mapBounds.getWest(), + }); }, [onBoundsChange], ); 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-stations.ts b/packages/react/src/hooks/use-stations.ts index 225b4718..60630c5a 100644 --- a/packages/react/src/hooks/use-stations.ts +++ b/packages/react/src/hooks/use-stations.ts @@ -1,12 +1,16 @@ -import { useQuery } from "@tanstack/react-query"; +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"; -export function useStations(params: StationsSearchParams = {}) { +type StationsQueryOptions = Pick, "placeholderData">; + +export function useStations(params: StationsSearchParams = {}, options: StationsQueryOptions = {}) { const { baseUrl } = useNeapsConfig(); return useQuery({ queryKey: ["neaps", "stations", params], queryFn: () => fetchStations(baseUrl, params), + ...options, }); } From 69c3a91fe67691c264e8c64e4447bbcfc5dd9adb Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Tue, 24 Feb 2026 06:46:36 -0500 Subject: [PATCH 04/48] Truncate station names in list --- packages/react/src/components/NearbyStations.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/NearbyStations.tsx b/packages/react/src/components/NearbyStations.tsx index 8044a566..f4cb07db 100644 --- a/packages/react/src/components/NearbyStations.tsx +++ b/packages/react/src/components/NearbyStations.tsx @@ -104,12 +104,12 @@ function NearbyFromPosition({
  • )} + + {children}
  • ); -} +}); diff --git a/packages/react/src/components/TideGraph.tsx b/packages/react/src/components/TideGraph.tsx index 8ff9f84d..d5ffc13e 100644 --- a/packages/react/src/components/TideGraph.tsx +++ b/packages/react/src/components/TideGraph.tsx @@ -61,7 +61,7 @@ export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { function getTimeRangeDates(range: TimeRange, base: Date = new Date()): { start: Date; end: Date } { const start = new Date(base); - start.setMinutes(0, 0, 0); + start.setHours(0, 0, 0, 0); const end = new Date(start); const days = range === "24h" ? 1 : range === "3d" ? 3 : 7; end.setDate(end.getDate() + days); @@ -278,7 +278,7 @@ function TideGraphChart({ export function TideGraph(props: TideGraphProps) { const config = useNeapsConfig(); const colors = useThemeColors(); - const [activeRange, setActiveRange] = useState(props.timeRange ?? "24h"); + const [activeRange, setActiveRange] = useState(props.timeRange ?? "3d"); // Data-driven mode: timeline/extremes passed directly if (props.timeline) { diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index e18e9b6e..c12526b2 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -23,7 +23,7 @@ function getDateRange(timeRange: TimeRange | { start: Date; end: Date }): { } { if (typeof timeRange === "object") return timeRange; const start = new Date(); - start.setMinutes(0, 0, 0); + start.setHours(0, 0, 0, 0); const end = new Date(start); const days = timeRange === "24h" ? 1 : timeRange === "3d" ? 3 : 7; end.setDate(end.getDate() + days); @@ -35,7 +35,7 @@ export function TideStation({ id, showGraph = true, showTable = true, - timeRange = "24h", + timeRange = "3d", className, }: TideStationProps) { const config = useNeapsConfig(); @@ -68,6 +68,7 @@ export function TideStation({ const s = station.data!; const units: Units = timeline.data?.units ?? config.units; + const datum = timeline.data?.datum ?? extremes.data?.datum; const timelineData = timeline.data?.timeline ?? []; const extremesData = extremes.data?.extremes ?? []; return ( @@ -80,7 +81,7 @@ export function TideStation({

    {s.name}

    - {[s.region, s.country].filter(Boolean).join(", ")} + {[s.region, s.country, s.timezone].filter(Boolean).join(" · ")}
    @@ -96,6 +97,7 @@ export function TideStation({ extremes={extremesData} timezone={s.timezone} units={units} + datum={datum} showTimeRangeSelector={false} className="px-4 pb-4" /> @@ -103,7 +105,7 @@ export function TideStation({ )} {showTable && (
    - +
    )}
    diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx index 8df8633a..2291d3b4 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -9,6 +9,7 @@ export interface TideTableDataProps { extremes: Extreme[]; timezone?: string; units?: Units; + datum?: string; } export interface TideTableFetchProps { @@ -27,11 +28,13 @@ function TideTableView({ extremes, timezone, units, + datum, className, }: { extremes: Extreme[]; timezone: string; units: Units; + datum?: string; className?: string; }) { const grouped = useMemo(() => { @@ -111,6 +114,13 @@ function TideTableView({ )} + {(datum || (timezone && timezone !== "UTC")) && ( +
    + {datum && Datum: {datum}} + {datum && timezone && · } + {timezone && {timezone}} +
    + )} ); } @@ -124,6 +134,7 @@ export function TideTable(props: TideTableProps) { extremes={props.extremes} timezone={props.timezone ?? "UTC"} units={props.units ?? config.units} + datum={props.datum} className={props.className} /> ); @@ -163,6 +174,7 @@ function TideTableFetcher({ extremes={data?.extremes ?? []} timezone={data?.station?.timezone ?? "UTC"} units={data?.units ?? config.units} + datum={data?.datum} className={className} /> ); diff --git a/packages/react/src/hooks/use-theme-colors.ts b/packages/react/src/hooks/use-theme-colors.ts index 6e5dc1e1..ad912676 100644 --- a/packages/react/src/hooks/use-theme-colors.ts +++ b/packages/react/src/hooks/use-theme-colors.ts @@ -3,6 +3,7 @@ import { useDarkMode } from "./use-dark-mode.js"; export interface ThemeColors { primary: string; + secondary: string; high: string; low: string; bg: string; @@ -14,6 +15,7 @@ export interface ThemeColors { const FALLBACKS: ThemeColors = { primary: "#2563eb", + secondary: "#f59e0b", high: "#3b82f6", low: "#f59e0b", bg: "#ffffff", @@ -52,6 +54,7 @@ export function useThemeColors(): ThemeColors { return useMemo( () => ({ primary: readCSSVar("--neaps-primary", FALLBACKS.primary), + secondary: readCSSVar("--neaps-secondary", FALLBACKS.secondary), high: readCSSVar("--neaps-high", FALLBACKS.high), low: readCSSVar("--neaps-low", FALLBACKS.low), bg: readCSSVar("--neaps-bg", FALLBACKS.bg), diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e6af8a4a..f5985dcf 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -28,7 +28,7 @@ export type { StationSearchProps } from "./components/StationSearch.js"; export { NearbyStations } from "./components/NearbyStations.js"; export type { NearbyStationsProps } from "./components/NearbyStations.js"; export { StationsMap } from "./components/StationsMap.js"; -export type { StationsMapProps } from "./components/StationsMap.js"; +export type { StationsMapProps, StationsMapRef } from "./components/StationsMap.js"; // Client export { diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css index 0dd38433..cdf894ec 100644 --- a/packages/react/src/styles.css +++ b/packages/react/src/styles.css @@ -6,6 +6,7 @@ :root { --neaps-primary: #2563eb; /* blue-600 */ + --neaps-secondary: #f59e0b; /* amber-500 */ --neaps-high: #3b82f6; /* blue-500 */ --neaps-low: #f59e0b; /* amber-500 */ --neaps-bg: #ffffff; /* white */ @@ -17,6 +18,7 @@ .dark { --neaps-primary: #60a5fa; /* blue-400 */ + --neaps-secondary: #fbbf24; /* amber-400 */ --neaps-high: #60a5fa; /* blue-400 */ --neaps-low: #fbbf24; /* amber-400 */ --neaps-bg: #0f172a; /* slate-900 */ From 7e11000a4a986a435b1e17ea70f2d098cf6f4985 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 27 Feb 2026 11:52:08 -0500 Subject: [PATCH 08/48] Major updates to react UI --- packages/react/.storybook/theme.ts | 28 +- packages/react/README.md | 28 +- packages/react/package.json | 19 +- .../src/components/StationDisclaimers.tsx | 10 + .../src/components/StationsMap.stories.tsx | 16 +- packages/react/src/components/StationsMap.tsx | 178 ++-- .../react/src/components/TideConditions.tsx | 150 ++- .../react/src/components/TideCycleGraph.tsx | 173 ++++ .../src/components/TideGraph.stories.tsx | 41 +- packages/react/src/components/TideGraph.tsx | 943 ++++++++++++------ .../src/components/TideStation.stories.tsx | 44 +- packages/react/src/components/TideStation.tsx | 74 +- .../src/components/TideStationHeader.tsx | 28 + packages/react/src/components/TideTable.tsx | 33 +- packages/react/src/hooks/use-current-level.ts | 45 + packages/react/src/hooks/use-theme-colors.ts | 12 +- packages/react/src/hooks/use-tide-chunks.ts | 179 ++++ packages/react/src/index.ts | 24 +- packages/react/src/provider.tsx | 10 +- packages/react/src/styles.css | 54 +- packages/react/src/utils/format.ts | 8 +- packages/react/src/utils/scales.ts | 61 ++ packages/react/src/utils/sun.ts | 60 ++ packages/react/tsdown.config.ts | 2 +- 24 files changed, 1565 insertions(+), 655 deletions(-) create mode 100644 packages/react/src/components/StationDisclaimers.tsx create mode 100644 packages/react/src/components/TideCycleGraph.tsx create mode 100644 packages/react/src/components/TideStationHeader.tsx create mode 100644 packages/react/src/hooks/use-current-level.ts create mode 100644 packages/react/src/hooks/use-tide-chunks.ts create mode 100644 packages/react/src/utils/scales.ts create mode 100644 packages/react/src/utils/sun.ts diff --git a/packages/react/.storybook/theme.ts b/packages/react/.storybook/theme.ts index 0f98df2e..9fe078fa 100644 --- a/packages/react/.storybook/theme.ts +++ b/packages/react/.storybook/theme.ts @@ -1,33 +1,7 @@ import { create } from "storybook/internal/theming"; export default create({ - base: "light", + base: "normal", brandTitle: "Neaps", brandUrl: "https://openwaters.io/tides/neaps", - - // Colors - colorPrimary: "#2563eb", - colorSecondary: "#2563eb", - - // UI - appBg: "#f8fafc", - appContentBg: "#ffffff", - appBorderColor: "#e2e8f0", - appBorderRadius: 8, - - // Text - textColor: "#0f172a", - textMutedColor: "#64748b", - textInverseColor: "#ffffff", - - // Toolbar - barTextColor: "#64748b", - barSelectedColor: "#2563eb", - barBg: "#ffffff", - - // Form - inputBg: "#ffffff", - inputBorder: "#e2e8f0", - inputTextColor: "#0f172a", - inputBorderRadius: 6, }); diff --git a/packages/react/README.md b/packages/react/README.md index 1dc62fb8..e118cb5a 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -45,12 +45,12 @@ function App() {
    ``` -| 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 | +| 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 | ### `` @@ -60,12 +60,12 @@ All-in-one display for a single station — name, graph, and table. ``` -| 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 | +| 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 | ### `` @@ -168,8 +168,8 @@ 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-high: #3b82f6; /* High tide color */ + --neaps-low: #f59e0b; /* Low tide color */ --neaps-bg: #ffffff; --neaps-bg-subtle: #f8fafc; --neaps-text: #0f172a; diff --git a/packages/react/package.json b/packages/react/package.json index 27ab9432..dfc098aa 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -33,6 +33,7 @@ ], "scripts": { "build": "tsdown", + "watch": "tsdown --watch", "prepack": "npm run build", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" @@ -53,11 +54,18 @@ }, "dependencies": { "@tanstack/react-query": "^5.64.0", - "chart.js": "^4.4.0", - "chartjs-adapter-date-fns": "^3.0.0", - "chartjs-plugin-annotation": "^3.1.0", - "chartjs-plugin-datalabels": "^2.2.0", - "react-chartjs-2": "^5.2.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", + "d3-array": "^3.2.1", + "date-fns": "^3.6.0" }, "devDependencies": { "@storybook/react": "^10.2.10", @@ -67,7 +75,6 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "canvas": "^3.2.1", "jsdom": "^28.1.0", "maplibre-gl": "^4.0.0", "react": "^19.0.0", 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/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx index a964fc0f..e2df5e13 100644 --- a/packages/react/src/components/StationsMap.stories.tsx +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -27,18 +27,7 @@ export const Default: Story = { export const USEastCoast: Story = { args: { mapStyle: "https://demotiles.maplibre.org/style.json", - center: [-71.05, 42.36], - zoom: 8, - onStationSelect: (station) => console.log("Selected:", station), - }, -}; - -export const NoSearch: Story = { - args: { - mapStyle: "https://demotiles.maplibre.org/style.json", - center: [-71.05, 42.36], - zoom: 8, - showSearch: false, + initialViewState: { longitude: -71.05, latitude: 42.36, zoom: 8 }, onStationSelect: (station) => console.log("Selected:", station), }, }; @@ -46,8 +35,7 @@ export const NoSearch: Story = { export const HighZoom: Story = { args: { mapStyle: "https://demotiles.maplibre.org/style.json", - center: [-71.05, 42.36], - zoom: 12, + initialViewState: { longitude: -71.05, latitude: 42.36, zoom: 12 }, onStationSelect: (station) => console.log("Selected:", station), }, }; diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index 17161d39..620651a3 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -1,4 +1,11 @@ -import { useState, useCallback, useMemo, useRef, useImperativeHandle, forwardRef, type ReactNode } from "react"; +import { + useState, + useCallback, + useMemo, + forwardRef, + type ComponentProps, + type ReactNode, +} from "react"; import { Map, Source, @@ -18,20 +25,17 @@ import { useExtremes } from "../hooks/use-extremes.js"; import { useNeapsConfig } from "../provider.js"; import { useDarkMode } from "../hooks/use-dark-mode.js"; import { useThemeColors } from "../hooks/use-theme-colors.js"; -import { StationSearch } from "./StationSearch.js"; import { formatLevel, formatTime } from "../utils/format.js"; import type { StationSummary, Extreme } from "../types.js"; -export interface StationsMapProps { - /** MapLibre style URL (required — e.g. MapTiler, Protomaps). */ - mapStyle: string; +// Props that StationsMap manages internally and cannot be overridden +type ManagedMapProps = "onMove" | "onClick" | "interactiveLayerIds" | "style" | "cursor"; + +export interface StationsMapProps extends Omit, ManagedMapProps> { /** Optional dark mode style URL. Switches automatically based on .dark class or prefers-color-scheme. */ darkMapStyle?: string; - center?: [longitude: number, latitude: number]; - zoom?: number; onStationSelect?: (station: StationSummary) => void; onBoundsChange?: (bounds: { north: number; south: number; east: number; west: number }) => void; - showSearch?: boolean; /** 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. */ @@ -50,17 +54,10 @@ export interface StationsMapProps { * - `false`: disables popups entirely (onStationSelect still fires) */ popupContent?: "preview" | "simple" | ((station: StationSummary) => ReactNode) | false; - /** Additional content rendered inside the map container (e.g. custom overlays, drawers). */ - children?: ReactNode; + /** CSS class applied to the outer wrapper div. */ className?: string; } -export interface StationsMapRef { - flyTo(options: { center: [longitude: number, latitude: number]; zoom?: number }): void; - panTo(center: [longitude: number, latitude: number]): void; - getViewState(): { longitude: number; latitude: number; zoom: number }; -} - function stationsToGeoJSON(stations: StationSummary[]): GeoJSON.FeatureCollection { return { type: "FeatureCollection", @@ -102,7 +99,7 @@ function StationPreviewCard({ stationId }: { stationId: string }) { {formatLevel(next.level, data.units ?? config.units)}{" "} - at {formatTime(next.time, data.station?.timezone ?? "UTC")} + at {formatTime(next.time, data.station?.timezone ?? "UTC", config.locale)} @@ -111,44 +108,26 @@ function StationPreviewCard({ stationId }: { stationId: string }) { ); } -export const StationsMap = forwardRef(function StationsMap({ - mapStyle, - darkMapStyle, - center = [0, 30], - zoom = 3, - onStationSelect, - onBoundsChange, - showSearch = true, - focusStation, - showGeolocation = true, - clustering = true, - clusterMaxZoom: clusterMaxZoomProp = 14, - clusterRadius: clusterRadiusProp = 50, - popupContent = "preview", - children, - className, -}, ref) { - const mapRef = useRef(null); - const [viewState, setViewState] = useState({ - longitude: center[0], - latitude: center[1], - zoom, - }); - - useImperativeHandle(ref, () => ({ - flyTo({ center: c, zoom: z }) { - mapRef.current?.flyTo({ center: c, zoom: z }); - }, - panTo(c) { - mapRef.current?.panTo(c); - }, - getViewState() { - return viewState; - }, - })); - const [bbox, setBbox] = useState< - [min: [number, number], max: [number, number]] | null - >(null); +export const StationsMap = forwardRef(function StationsMap( + { + mapStyle, + darkMapStyle, + onStationSelect, + onBoundsChange, + focusStation, + showGeolocation = true, + clustering = true, + clusterMaxZoom: clusterMaxZoomProp = 14, + clusterRadius: clusterRadiusProp = 50, + popupContent = "preview", + children, + className, + ...mapProps + }, + ref, +) { + const [viewState, setViewState] = useState(mapProps.initialViewState ?? {}); + const [bbox, setBbox] = useState<[min: [number, number], max: [number, number]] | null>(null); const debouncedSetBbox = useDebouncedCallback(setBbox, 200); const [selectedStation, setSelectedStation] = useState(null); @@ -156,7 +135,11 @@ export const StationsMap = forwardRef(function const colors = useThemeColors(); const effectiveMapStyle = isDarkMode && darkMapStyle ? darkMapStyle : mapStyle; - const { data: stations = [] } = useStations(bbox ? { bbox } : {}, { + const { + data: stations = [], + isLoading, + isError, + } = useStations(bbox ? { bbox } : {}, { placeholderData: keepPreviousData, }); @@ -165,10 +148,12 @@ export const StationsMap = forwardRef(function const { data: fetchedFocusStation } = useStation( focusStation && !focusStationInList ? focusStation : undefined, ); - const focusStationData: StationSummary | undefined = focusStationInList ?? (fetchedFocusStation as StationSummary | undefined); + const focusStationData: StationSummary | undefined = + focusStationInList ?? (fetchedFocusStation as StationSummary | undefined); const geojson = useMemo( - () => stationsToGeoJSON(focusStation ? stations.filter((s) => s.id !== focusStation) : stations), + () => + stationsToGeoJSON(focusStation ? stations.filter((s) => s.id !== focusStation) : stations), [stations, focusStation], ); @@ -192,19 +177,6 @@ export const StationsMap = forwardRef(function [onBoundsChange], ); - const handleSearchSelect = useCallback( - (station: StationSummary) => { - setViewState((prev) => ({ - ...prev, - longitude: station.longitude, - latitude: station.latitude, - zoom: 10, - })); - onStationSelect?.(station); - }, - [onStationSelect], - ); - const handleMapClick = useCallback( (e: MapLayerMouseEvent) => { const feature = e.features?.[0]; @@ -265,9 +237,10 @@ export const StationsMap = forwardRef(function }, []); return ( -
    +
    (function mapStyle={effectiveMapStyle} style={{ width: "100%", height: "100%" }} cursor="pointer" + attributionControl={false} > (function type="circle" filter={["!", ["has", "point_count"]]} paint={{ - "circle-color": ["match", ["get", "type"], "subordinate", colors.secondary, colors.primary], + "circle-color": [ + "match", + ["get", "type"], + "subordinate", + colors.secondary, + colors.primary, + ], "circle-radius": 6, "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", @@ -419,13 +399,6 @@ export const StationsMap = forwardRef(function )} - {/* Search overlay */} - {showSearch && ( -
    - -
    - )} - {/* Locate me button */} {showGeolocation && "geolocation" in navigator && ( )} + {isError && ( +
    +
    + + + + + + Failed to load stations +
    +
    + )} + + {isLoading && ( +
    +
    + + + + Loading stations… +
    +
    + )} + {children}
    ); diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx index f1c85a5b..bdcc20f9 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -1,26 +1,17 @@ +import { useCurrentLevel } from "../hooks/use-current-level.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"; export interface TideConditionsProps { timeline: TimelineEntry[]; extremes: Extreme[]; units: Units; + timezone: string; className?: string; } -function getCurrentLevel(timeline: TimelineEntry[]): number | null { - const now = Date.now(); - for (let i = 1; i < timeline.length; i++) { - const t1 = new Date(timeline[i - 1].time).getTime(); - const t2 = new Date(timeline[i].time).getTime(); - if (now >= t1 && now <= t2) { - const ratio = (now - t1) / (t2 - t1); - return timeline[i - 1].level + ratio * (timeline[i].level - timeline[i - 1].level); - } - } - return null; -} - function getNextExtreme(extremes: Extreme[]): Extreme | null { const now = new Date(); return extremes.find((e) => new Date(e.time) > now) ?? null; @@ -32,46 +23,115 @@ function isTideRising(extremes: Extreme[]): boolean | null { return next.high; } -function formatTimeUntil(isoTime: string): string { - const diff = new Date(isoTime).getTime() - Date.now(); - if (diff <= 0) return "now"; - const hours = Math.floor(diff / (1000 * 60 * 60)); - const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; +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: string; + units: Units; + locale: string; + state?: TideState; + variant?: "left" | "right"; +}) { + const stateIcon = state ? STATE_ICON[state] : null; + return ( +
    +
    {label}
    + + {formatLevel(level, units)} + {stateIcon && ( + + {stateIcon.icon} + + )} + + + {new Date(time).toLocaleString(locale, { + timeStyle: "short", + })} + +
    + ); } -export function TideConditions({ timeline, extremes, units, className }: TideConditionsProps) { - const currentLevel = getCurrentLevel(timeline); +export function TideConditions({ + timeline, + extremes, + units, + className, + timezone, +}: TideConditionsProps) { + const { locale } = useNeapsConfig(); + const currentLevel = useCurrentLevel(timeline); const nextExtreme = getNextExtreme(extremes); const rising = isTideRising(extremes); + if (!currentLevel) { + return ( +
    +

    No tide data available

    +
    + ); + } + return ( -
    - {currentLevel !== null && ( -
    - - {formatLevel(currentLevel, units)} - - {rising !== null && ( - - {rising ? "\u2191" : "\u2193"} - - )} +
    +
    + +
    +

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

    - )} - {nextExtreme && ( -
    - - {nextExtreme.high ? "High" : "Low"}: - {formatLevel(nextExtreme.level, units)} - in {formatTimeUntil(nextExtreme.time)} - +
    + + + {nextExtreme && ( + + )}
    - )} +
    ); } diff --git a/packages/react/src/components/TideCycleGraph.tsx b/packages/react/src/components/TideCycleGraph.tsx new file mode 100644 index 00000000..9db8f092 --- /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 "../utils/scales.js"; +import type { Extreme, TimelineEntry } from "../types.js"; + +const HALF_WINDOW_MS = 6.417 * 60 * 60 * 1000; +const MARGIN: Margin = { top: 0, right: 0, bottom: 0, left: 0 }; + +export interface TideCycleGraphProps { + timeline: TimelineEntry[]; + extremes: Extreme[]; + className?: string; +} + +const getX = (d: TimelineEntry) => new Date(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_WINDOW_MS; + const windowEnd = now + HALF_WINDOW_MS; + + const windowTimeline = useMemo( + () => + timeline.filter((e) => { + const t = new Date(e.time).getTime(); + return t >= windowStart && t <= windowEnd; + }), + [timeline, windowStart, windowEnd], + ); + + const windowExtremes = useMemo( + () => + extremes.filter((e) => { + const t = new Date(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.stories.tsx b/packages/react/src/components/TideGraph.stories.tsx index b0dce1c7..d059fd26 100644 --- a/packages/react/src/components/TideGraph.stories.tsx +++ b/packages/react/src/components/TideGraph.stories.tsx @@ -7,16 +7,8 @@ const meta: Meta = { component: TideGraph, argTypes: { id: { control: "text" }, - timeRange: { control: "radio", options: ["24h", "3d", "7d"] }, - showTimeRangeSelector: { control: "boolean" }, + pxPerDay: { control: { type: "range", min: 100, max: 400, step: 25 } }, }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], }; export default meta; @@ -25,53 +17,43 @@ type Story = StoryObj; export const Default: Story = { args: { id: "noaa/8443970", - timeRange: "24h", - }, -}; - -export const ThreeDays: Story = { - args: { - id: "noaa/8443970", - timeRange: "3d", }, }; -export const SevenDays: Story = { +export const DenseScale: Story = { args: { id: "noaa/8443970", - timeRange: "7d", + pxPerDay: 100, }, }; -export const WithTimeRangeSelector: Story = { +export const WideScale: Story = { args: { id: "noaa/8443970", - showTimeRangeSelector: true, + pxPerDay: 350, }, }; -export const NarrowWidth: Story = { +export const MobileWidth: Story = { args: { id: "noaa/8443970", - timeRange: "24h", }, decorators: [ (Story) => ( -
    +
    ), ], }; -export const MediumWidth: Story = { +export const DesktopWidth: Story = { args: { id: "noaa/8443970", - timeRange: "3d", }, decorators: [ (Story) => ( -
    +
    ), @@ -81,11 +63,10 @@ export const MediumWidth: Story = { export const DarkMode: Story = { args: { id: "noaa/8443970", - timeRange: "24h", }, decorators: [ (Story) => ( -
    +
    ), @@ -95,7 +76,6 @@ export const DarkMode: Story = { export const Loading: Story = { args: { id: "noaa/8443970", - timeRange: "24h", }, decorators: [ (Story) => ( @@ -109,6 +89,5 @@ export const Loading: Story = { export const Error: Story = { args: { id: "nonexistent/station", - timeRange: "24h", }, }; diff --git a/packages/react/src/components/TideGraph.tsx b/packages/react/src/components/TideGraph.tsx index d5ffc13e..d3cf0a77 100644 --- a/packages/react/src/components/TideGraph.tsx +++ b/packages/react/src/components/TideGraph.tsx @@ -1,43 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from "react"; -import { Line } from "react-chartjs-2"; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - PointElement, - LineElement, - TimeScale, - Filler, - Tooltip, - type ChartOptions, - type ChartData, - type Plugin, -} from "chart.js"; -import annotationPlugin from "chartjs-plugin-annotation"; -import ChartDataLabels from "chartjs-plugin-datalabels"; -import "chartjs-adapter-date-fns"; - -import { useTimeline, type UseTimelineParams } from "../hooks/use-timeline.js"; -import { useExtremes, type UseExtremesParams } from "../hooks/use-extremes.js"; +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { AreaClosed, LinePath } from "@visx/shape"; +import { AxisTop, AxisLeft } from "@visx/axis"; +import { Group } from "@visx/group"; +import { curveNatural } from "@visx/curve"; +import { useTooltip } from "@visx/tooltip"; +import { localPoint } from "@visx/event"; +import { bisector } from "d3-array"; + +import { useTideChunks } from "../hooks/use-tide-chunks.js"; +import { useCurrentLevel } from "../hooks/use-current-level.js"; import { useNeapsConfig } from "../provider.js"; -import { useThemeColors, withAlpha, type ThemeColors } from "../hooks/use-theme-colors.js"; import { formatLevel, formatTime } from "../utils/format.js"; +import { useTideScales, type Margin } from "../utils/scales.js"; +import { getNightIntervals } from "../utils/sun.js"; import type { TimelineEntry, Extreme, Units } from "../types.js"; -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - TimeScale, - Filler, - Tooltip, - annotationPlugin, - ChartDataLabels, -); - -export type TimeRange = "24h" | "3d" | "7d"; - export interface TideGraphDataProps { timeline: TimelineEntry[]; extremes?: Extreme[]; @@ -48,25 +25,20 @@ export interface TideGraphDataProps { export interface TideGraphFetchProps { id: string; - start?: Date; - end?: Date; timeline?: undefined; } export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { - timeRange?: TimeRange; - showTimeRangeSelector?: boolean; + pxPerDay?: number; className?: string; }; -function getTimeRangeDates(range: TimeRange, base: Date = new Date()): { start: Date; end: Date } { - const start = new Date(base); - start.setHours(0, 0, 0, 0); - const end = new Date(start); - const days = range === "24h" ? 1 : range === "3d" ? 3 : 7; - end.setDate(end.getDate() + days); - return { start, end }; -} +const PX_PER_DAY_DEFAULT = 200; +const HEIGHT = 300; +const MARGIN: Margin = { top: 65, right: 0, bottom: 40, left: 60 }; +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +const timelineBisector = bisector((d) => new Date(d.time).getTime()).left; function useContainerWidth() { const ref = useRef(null); @@ -87,12 +59,7 @@ function useContainerWidth() { return { ref, width }; } -function getMaxTicksLimit(width: number): number { - if (width < 300) return 4; - if (width < 500) return 6; - if (width < 700) return 8; - return 12; -} +// ─── Static (data-driven) chart ───────────────────────────────────────────── function TideGraphChart({ timeline, @@ -100,7 +67,11 @@ function TideGraphChart({ timezone, units, datum, - colors, + locale, + svgWidth, + yDomainOverride, + latitude, + longitude, className, }: { timeline: TimelineEntry[]; @@ -108,247 +79,531 @@ function TideGraphChart({ timezone: string; units: Units; datum?: string; - colors: ThemeColors; + locale: string; + svgWidth: number; + yDomainOverride?: [number, number]; + latitude?: number; + longitude?: number; className?: string; }) { - const { ref: containerRef, width: containerWidth } = useContainerWidth(); - const maxTicks = getMaxTicksLimit(containerWidth); - - // Inline plugin: applies a vertical gradient fill to the Water Level dataset. - // Sets the gradient on the resolved element options so the Filler plugin picks it up. - const gradientFillPlugin: Plugin<"line"> = useMemo( - () => ({ - id: "gradientFill", - beforeDraw(chart) { - const { ctx, chartArea } = chart; - if (!chartArea) return; - const meta = chart.getDatasetMeta(1); // Water Level - if (!meta?.dataset || !meta.data.length) return; - const yScale = chart.scales.y; - if (!yScale) return; - // Gradient spans from y=0 (origin, transparent) to the peak data point (opaque) - const originPixel = yScale.getPixelForValue(0); - let topPixel = originPixel; - for (const pt of meta.data) { - if (pt.y < topPixel) topPixel = pt.y; - } - const gradient = ctx.createLinearGradient(0, originPixel, 0, topPixel); - gradient.addColorStop(0, withAlpha(colors.primary, 0.05)); - gradient.addColorStop(1, withAlpha(colors.primary, 0.5)); - meta.dataset.options.backgroundColor = gradient; - }, - }), - [colors.primary], - ); + const gradientId = useId(); + const currentLevel = useCurrentLevel(timeline); - const data: ChartData<"line"> = useMemo( - () => ({ - datasets: [ - { - data: extremes.map((e) => ({ x: new Date(e.time).getTime(), y: e.level })), - backgroundColor: extremes.map((e) => (e.high ? colors.high : colors.low)), - pointRadius: 5, - pointHoverRadius: 7, - showLine: false, - clip: false, - datalabels: { - display: true, - align: (ctx) => (extremes[ctx.dataIndex]?.high ? "top" : "bottom") as "top" | "bottom", - anchor: (ctx) => (extremes[ctx.dataIndex]?.high ? "end" : "start"), - formatter: (_value, ctx) => { - const e = extremes[ctx.dataIndex]; - if (!e) return ""; - return [formatTime(e.time, timezone), formatLevel(e.level, units)]; - }, - color: (ctx) => (extremes[ctx.dataIndex]?.high ? colors.high : colors.low), - font: { size: 10, weight: "bold" }, - textAlign: "center", - clamp: true, - }, - }, - { - label: "Water Level", - data: timeline.map((p) => ({ x: new Date(p.time).getTime(), y: p.level })), - borderColor: colors.primary, - backgroundColor: withAlpha(colors.primary, 0.15), - fill: "origin", - tension: 0.4, - pointRadius: 0, - pointHitRadius: 8, - borderWidth: 2, - datalabels: { display: false }, - }, - ], - }), - [timeline, extremes, colors, timezone, units], - ); + const { xScale, yScale, innerW, innerH } = useTideScales({ + timeline, + extremes, + width: svgWidth, + height: HEIGHT, + margin: MARGIN, + yDomainOverride, + }); - const options: ChartOptions<"line"> = useMemo( - () => ({ - responsive: true, - maintainAspectRatio: false, - interaction: { - intersect: false, - mode: "index", - }, - scales: { - x: { - type: "time", - time: { - unit: "day", - tooltipFormat: "PPp", - displayFormats: { - hour: "ha", - day: "MMM d", - }, - }, - adapters: { - date: { timeZone: timezone }, - }, - grid: { - color: colors.border, - }, - ticks: { - color: colors.text, - maxTicksLimit: maxTicks, - }, - }, - y: { - grace: "70%", - grid: { - color: colors.border, - }, - ticks: { - color: colors.text, - callback: (value) => formatLevel(value as number, units), - }, - ...(datum && { - title: { - display: true, - text: datum, - color: colors.textMuted, - }, - }), - }, - }, - plugins: { - annotation: { - annotations: { - nowLine: { - type: "line", - xMin: Date.now(), - xMax: Date.now(), - borderColor: colors.primary, - borderWidth: 2, - borderDash: [2, 4], - label: { - display: true, - content: "Now", - position: "start", - color: colors.textMuted, - backgroundColor: "transparent", - font: { size: 12 }, - }, - }, - }, - }, - tooltip: { - callbacks: { - label: (ctx) => { - if (ctx.datasetIndex === 0) { - const extreme = extremes[ctx.dataIndex]; - if (extreme) return `${extreme.label}: ${formatLevel(extreme.level, units)}`; - } - return formatLevel(ctx.parsed.y ?? 0, units); - }, - }, - }, - }, - }), - [timezone, units, datum, extremes, maxTicks, colors], + const { showTooltip, hideTooltip, tooltipData } = useTooltip(); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType === "touch") return; + const point = localPoint(event); + if (!point) return; + 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; + const d = d1 && x0 - new Date(d0.time).getTime() > new Date(d1.time).getTime() - x0 ? d1 : d0; + showTooltip({ tooltipData: d }); + }, + [xScale, timeline, showTooltip], ); + const nightIntervals = useMemo(() => { + if (latitude == null || longitude == null || !timeline.length) return []; + const [start, end] = xScale.domain(); + return getNightIntervals(latitude, longitude, start.getTime(), end.getTime()); + }, [latitude, longitude, timeline.length, xScale]); + + 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]); + + if (innerW <= 0 || svgWidth <= 0) return null; + return ( -
    - -
    + + + + + + + + + + + + + + + + + + + + {/* Night bands */} + {nightIntervals.map((interval, i) => { + const x1 = xScale(interval.start); + const x2 = xScale(interval.end); + return ( + + ); + })} + + {/* Zero reference line */} + + + {/* Area fill: positive (above zero) */} + xScale(new Date(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(new Date(d.time).getTime())} + y={(d) => yScale(d.level)} + yScale={zeroBaseScale} + curve={curveNatural} + fill={`url(#${gradientId}-neg)`} + clipPath={`url(#${gradientId}-clip-neg)`} + /> + xScale(new Date(d.time).getTime())} + y={(d) => yScale(d.level)} + curve={curveNatural} + stroke="var(--neaps-primary)" + strokeWidth={2} + /> + + {/* Active point: shows hovered point, or current level when idle */} + {(() => { + const active = tooltipData ?? currentLevel; + if (!active) return null; + const x = xScale(new Date(active.time).getTime()); + return ( + + + + + + {formatTime(active.time, timezone, locale)} + + + {formatLevel(active.level, units)} + + + + ); + })()} + + {/* Extreme points + labels */} + {extremes.map((e, i) => { + const cx = xScale(new Date(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 [start, end] = xScale.domain(); + 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); + } + const fmt = new Intl.DateTimeFormat(locale, { timeZone: timezone, month: "short" }); + const months = dates.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", + }} + /> + ); + })()} + + {/* Datum label */} + {datum && ( + + {datum} + + )} + + {/* Tooltip hit area */} + + + ); } -export function TideGraph(props: TideGraphProps) { - const config = useNeapsConfig(); - const colors = useThemeColors(); - const [activeRange, setActiveRange] = useState(props.timeRange ?? "3d"); +// ─── Y-axis overlay (stays fixed on the left while chart scrolls) ─────────── - // Data-driven mode: timeline/extremes passed directly - if (props.timeline) { - return ( - - ); - } - - // Fetch mode: id provided +function YAxisOverlay({ + yScale, + innerH, + narrowRange, + unitSuffix, + datum, +}: { + yScale: ReturnType["yScale"]; + innerH: number; + narrowRange: boolean; + unitSuffix: string; + datum?: string; +}) { return ( - +
    + + + + `${narrowRange ? Number(v).toFixed(1) : Math.round(Number(v))} ${unitSuffix}` + } + tickLabelProps={{ + fill: "var(--neaps-text-muted)", + fontSize: 11, + textAnchor: "end", + dx: -4, + dy: 4, + style: { fontVariantNumeric: "tabular-nums" }, + }} + /> + {datum && ( + + {datum} + + )} + + +
    ); } -function TideGraphFetcher({ +// ─── Scrollable chart (fetch mode) ────────────────────────────────────────── + +function TideGraphScroll({ id, - start, - end, - activeRange, - setActiveRange, - showTimeRangeSelector, - colors, + pxPerDay, + locale, className, }: { id: string; - start?: Date; - end?: Date; - activeRange: TimeRange; - setActiveRange: (r: TimeRange) => void; - showTimeRangeSelector?: boolean; - colors: ThemeColors; + pxPerDay: number; + locale: string; className?: string; }) { - const config = useNeapsConfig(); - const rangeDates = useMemo(() => getTimeRangeDates(activeRange), [activeRange]); - const effectiveStart = start ?? rangeDates.start; - const effectiveEnd = end ?? rangeDates.end; - - const timelineParams: UseTimelineParams = { - id, - start: effectiveStart.toISOString(), - end: effectiveEnd.toISOString(), - }; - const extremesParams: UseExtremesParams = { - id, - start: effectiveStart.toISOString(), - end: effectiveEnd.toISOString(), - }; - - const timeline = useTimeline(timelineParams); - const extremes = useExtremes(extremesParams); - - if (timeline.isLoading || extremes.isLoading) { + 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, + datum, + } = useTideChunks({ id }); + + const totalMs = dataEnd - dataStart; + const totalDays = totalMs / MS_PER_DAY; + const svgWidth = Math.max(1, totalDays * pxPerDay + MARGIN.left + MARGIN.right); + + // Y-axis scales (for the overlay) + const { yScale, innerH } = 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"; + + // Scroll to "now" on initial data load + useEffect(() => { + if (hasScrolledToNow.current || !timeline.length || !scrollRef.current) return; + const container = scrollRef.current; + const nowMs = Date.now(); + const nowFraction = (nowMs - dataStart) / totalMs; + const nowPx = nowFraction * (svgWidth - MARGIN.left - MARGIN.right) + MARGIN.left; + container.scrollLeft = nowPx - container.clientWidth / 2; + hasScrolledToNow.current = true; + prevDataStartRef.current = dataStart; + prevScrollWidthRef.current = container.scrollWidth; + }, [timeline.length, dataStart, totalMs, svgWidth]); + + // Preserve scroll position when chunks prepend (leftward) + useLayoutEffect(() => { + 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]); + + // Track whether "now" is visible and which direction it is + const [todayDirection, setTodayDirection] = useState<"left" | "right" | null>(null); + + const getNowPx = useCallback(() => { + const nowMs = Date.now(); + return ((nowMs - dataStart) / totalMs) * (svgWidth - MARGIN.left - MARGIN.right) + MARGIN.left; + }, [dataStart, totalMs, svgWidth]); + + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + function onScroll() { + const nowPx = getNowPx(); + const left = container!.scrollLeft; + const right = left + container!.clientWidth; + if (nowPx < left) setTodayDirection("left"); + else if (nowPx > right) setTodayDirection("right"); + else setTodayDirection(null); + } + container.addEventListener("scroll", onScroll, { passive: true }); + return () => container.removeEventListener("scroll", onScroll); + }, [getNowPx]); + + // Scroll to now handler + const scrollToNow = useCallback(() => { + const container = scrollRef.current; + if (!container) return; + container.scrollTo({ left: getNowPx() - container.clientWidth / 2, behavior: "smooth" }); + }, [getNowPx]); + + if (isLoading && !timeline.length) { return (
    Loading tide data... @@ -356,58 +611,118 @@ function TideGraphFetcher({ ); } - if (timeline.error || extremes.error) { + if (error && !timeline.length) { return (
    - {(timeline.error ?? extremes.error)?.message} + {error.message}
    ); } - const station = timeline.data?.station ?? extremes.data?.station; - const timezone = station?.timezone ?? "UTC"; - return (
    - {showTimeRangeSelector !== false && ( - - )} - -
    - ); -} +
    + {/* Scrollable chart area */} +
    +
    + {/* Left sentinel */} +
    -function TimeRangeSelector({ - active, - onChange, -}: { - active: TimeRange; - onChange: (r: TimeRange) => void; -}) { - const ranges: TimeRange[] = ["24h", "3d", "7d"]; - return ( -
    - {ranges.map((r) => ( + + + {/* Right sentinel */} +
    +
    + + {/* Edge loading indicators */} + {isLoadingPrevious && ( +
    + Loading... +
    + )} + {isLoadingNext && ( +
    + Loading... +
    + )} +
    + + {/* Right edge fade */} +
    + + {/* Y-axis overlay (fixed left) */} + + + {/* Today button — fades in when today is out of view, positioned toward today */} - ))} +
    ); } + +// ─── Public component ─────────────────────────────────────────────────────── + +export function TideGraph(props: TideGraphProps) { + const config = useNeapsConfig(); + const pxPerDay = props.pxPerDay ?? PX_PER_DAY_DEFAULT; + + // Data-driven mode: timeline/extremes passed directly (non-scrollable) + if (props.timeline) { + const { ref: containerRef, width: containerWidth } = useContainerWidth(); + return ( +
    + {containerWidth > 0 && ( + + )} +
    + ); + } + + // Fetch mode: scrollable infinite chart + return ( + + ); +} diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx index 7b625a0e..cbef3a14 100644 --- a/packages/react/src/components/TideStation.stories.tsx +++ b/packages/react/src/components/TideStation.stories.tsx @@ -30,22 +30,6 @@ export const WithTable: Story = { }, }; -export const TableOnly: Story = { - args: { - id: "noaa/8443970", - showGraph: false, - showTable: true, - }, -}; - -export const ThreeDayRange: Story = { - args: { - id: "noaa/8443970", - timeRange: "3d", - showTable: true, - }, -}; - export const WidgetSize: Story = { args: { id: "noaa/8443970", @@ -102,6 +86,34 @@ export const DarkMode: Story = { ], }; +export const FrenchLocale: Story = { + args: { + id: "noaa/8443970", + showTable: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + +export const ImperialUnits: Story = { + args: { + id: "noaa/8443970", + showTable: true, + }, + decorators: [ + (Story) => ( + + + + ), + ], +}; + export const Loading: Story = { args: { id: "noaa/8443970", diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index c12526b2..9566fe39 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -5,45 +5,39 @@ 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, type TimeRange } from "./TideGraph.js"; +import { TideGraph } from "./TideGraph.js"; import { TideTable } from "./TideTable.js"; import type { Units } from "../types.js"; +import { TideStationHeader } from "./TideStationHeader.js"; +import { StationDisclaimers } from "./StationDisclaimers.js"; export interface TideStationProps { id: string; showGraph?: boolean; showTable?: boolean; - timeRange?: TimeRange | { start: Date; end: Date }; className?: string; } -function getDateRange(timeRange: TimeRange | { start: Date; end: Date }): { - start: Date; - end: Date; -} { - if (typeof timeRange === "object") return timeRange; +function getDefaultRange(): { start: string; end: string } { const start = new Date(); start.setHours(0, 0, 0, 0); const end = new Date(start); - const days = timeRange === "24h" ? 1 : timeRange === "3d" ? 3 : 7; - end.setDate(end.getDate() + days); - return { start, end }; + end.setDate(end.getDate() + 7); + return { start: start.toISOString(), end: end.toISOString() }; } - export function TideStation({ id, showGraph = true, showTable = true, - timeRange = "3d", className, }: TideStationProps) { const config = useNeapsConfig(); - const { start, end } = useMemo(() => getDateRange(timeRange), [timeRange]); + const range = useMemo(getDefaultRange, []); const station = useStation(id); - const timeline = useTimeline({ id, start: start.toISOString(), end: end.toISOString() }); - const extremes = useExtremes({ id, start: start.toISOString(), end: end.toISOString() }); + 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 ( @@ -71,44 +65,24 @@ export function TideStation({ const datum = timeline.data?.datum ?? extremes.data?.datum; const timelineData = timeline.data?.timeline ?? []; const extremesData = extremes.data?.extremes ?? []; + return ( -
    -
    -
    -
    -

    {s.name}

    -
    - - {[s.region, s.country, s.timezone].filter(Boolean).join(" · ")} - -
    - -
    +
    + + + + + + {showGraph && } - {(showGraph || showTable) && ( -
    - {showGraph && ( -
    - -
    - )} - {showTable && ( -
    - -
    - )} -
    + {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.tsx b/packages/react/src/components/TideTable.tsx index 2291d3b4..cdddf6ad 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -29,12 +29,14 @@ function TideTableView({ timezone, units, datum, + locale, className, }: { extremes: Extreme[]; timezone: string; units: Units; datum?: string; + locale: string; className?: string; }) { const grouped = useMemo(() => { @@ -42,28 +44,30 @@ function TideTableView({ for (const extreme of extremes) { const key = getDateKey(extreme.time, timezone); if (!groups.has(key)) { - groups.set(key, { label: formatDate(extreme.time, timezone), extremes: [] }); + groups.set(key, { label: formatDate(extreme.time, timezone, locale), extremes: [] }); } groups.get(key)!.extremes.push(extreme); } return Array.from(groups.values()); - }, [extremes, timezone]); + }, [extremes, timezone, locale]); const now = new Date(); let foundNext = false; return ( -
    +
    - - - ) : null} - @@ -135,6 +144,7 @@ export function TideTable(props: TideTableProps) { timezone={props.timezone ?? "UTC"} units={props.units ?? config.units} datum={props.datum} + locale={config.locale} className={props.className} /> ); @@ -175,6 +185,7 @@ function TideTableFetcher({ timezone={data?.station?.timezone ?? "UTC"} units={data?.units ?? config.units} datum={data?.datum} + locale={config.locale} className={className} /> ); 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..e0caf4a1 --- /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 (new Date(timeline[i].time).getTime() <= at) lo = i; + else if (hi === -1) { + hi = i; + break; + } + } + + if (lo === -1 || hi === -1) return null; + + const t0 = new Date(timeline[lo].time).getTime(); + const t1 = new Date(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).toISOString(), 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; + if (now !== next) setNow(next); + }, 5_000); + return () => clearInterval(id); + }, []); + + return useMemo(() => interpolateLevel(timeline, now), [timeline, now]); +} diff --git a/packages/react/src/hooks/use-theme-colors.ts b/packages/react/src/hooks/use-theme-colors.ts index ad912676..ffdb3690 100644 --- a/packages/react/src/hooks/use-theme-colors.ts +++ b/packages/react/src/hooks/use-theme-colors.ts @@ -6,6 +6,7 @@ export interface ThemeColors { secondary: string; high: string; low: string; + danger: string; bg: string; bgSubtle: string; text: string; @@ -14,10 +15,11 @@ export interface ThemeColors { } const FALLBACKS: ThemeColors = { - primary: "#2563eb", - secondary: "#f59e0b", - high: "#3b82f6", - low: "#f59e0b", + primary: "#0284c7", + secondary: "#7c3aed", + high: "#0d9488", + low: "#d97706", + danger: "#ef4444", bg: "#ffffff", bgSubtle: "#f8fafc", text: "#0f172a", @@ -57,13 +59,13 @@ export function useThemeColors(): ThemeColors { 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: readCSSVar("--neaps-bg", FALLBACKS.bg), bgSubtle: readCSSVar("--neaps-bg-subtle", FALLBACKS.bgSubtle), text: readCSSVar("--neaps-text", FALLBACKS.text), textMuted: readCSSVar("--neaps-text-muted", FALLBACKS.textMuted), border: readCSSVar("--neaps-border", FALLBACKS.border), }), - // eslint-disable-next-line react-hooks/exhaustive-deps [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..b929fcba --- /dev/null +++ b/packages/react/src/hooks/use-tide-chunks.ts @@ -0,0 +1,179 @@ +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"; + +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 } = useNeapsConfig(); + const [chunks, setChunks] = useState(getInitialChunks); + const yDomainRef = useRef<[number, number] | null>(null); + + const timelineQueries = useQueries({ + queries: chunks.map((chunk) => ({ + queryKey: ["neaps", "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: ["neaps", "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) { + if (!seen.has(entry.time)) { + seen.add(entry.time); + result.push(entry); + } + } + } + result.sort((a, b) => new Date(a.time).getTime() - new Date(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) { + if (!seen.has(entry.time)) { + seen.add(entry.time); + result.push(entry); + } + } + } + result.sort((a, b) => new Date(a.time).getTime() - new Date(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: station?.timezone ?? "UTC", + units: firstTimeline?.data?.units ?? units, + datum: firstTimeline?.data?.datum ?? firstExtremes?.data?.datum, + }; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index f5985dcf..eb67e1cd 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -15,20 +15,16 @@ export { useThemeColors, withAlpha } from "./hooks/use-theme-colors.js"; export type { ThemeColors } from "./hooks/use-theme-colors.js"; // Components -export { TideStation } from "./components/TideStation.js"; -export type { TideStationProps } from "./components/TideStation.js"; -export { TideConditions } from "./components/TideConditions.js"; -export type { TideConditionsProps } from "./components/TideConditions.js"; -export { TideGraph } from "./components/TideGraph.js"; -export type { TideGraphProps, TimeRange } from "./components/TideGraph.js"; -export { TideTable } from "./components/TideTable.js"; -export type { TideTableProps } from "./components/TideTable.js"; -export { StationSearch } from "./components/StationSearch.js"; -export type { StationSearchProps } from "./components/StationSearch.js"; -export { NearbyStations } from "./components/NearbyStations.js"; -export type { NearbyStationsProps } from "./components/NearbyStations.js"; -export { StationsMap } from "./components/StationsMap.js"; -export type { StationsMapProps, StationsMapRef } from "./components/StationsMap.js"; +export * from "./components/TideStationHeader.js"; +export * from "./components/TideStation.js"; +export * from "./components/TideConditions.js"; +export * from "./components/TideCycleGraph.js"; +export * from "./components/TideGraph.js"; +export * from "./components/TideTable.js"; +export * from "./components/StationDisclaimers.js"; +export * from "./components/StationSearch.js"; +export * from "./components/NearbyStations.js"; +export * from "./components/StationsMap.js"; // Client export { diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index c5be4d0e..0da7a237 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, type ReactNode } from "react"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Units } from "./types.js"; @@ -7,6 +7,7 @@ export interface NeapsConfig { baseUrl: string; units: Units; datum?: string; + locale: string; } const NeapsContext = createContext(null); @@ -31,6 +32,7 @@ export interface NeapsProviderProps { baseUrl: string; units?: Units; datum?: string; + locale?: string; queryClient?: QueryClient; children: ReactNode; } @@ -39,10 +41,14 @@ export function NeapsProvider({ baseUrl, units = "meters", datum, + locale = typeof navigator !== "undefined" ? navigator.language : "en-US", queryClient, children, }: NeapsProviderProps) { - const config: NeapsConfig = { baseUrl, units, datum }; + const config = useMemo( + () => ({ baseUrl, units, datum, locale }), + [baseUrl, units, datum, locale], + ); return ( diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css index cdf894ec..98de452c 100644 --- a/packages/react/src/styles.css +++ b/packages/react/src/styles.css @@ -1,31 +1,45 @@ /* Neaps React Component Library — Theme Variables * - * These use Tailwind's default color palette (hex values). - * Override any --neaps-* variable to customize the theme. + * References Tailwind v4 CSS variables with hex fallbacks + * for consumers not using Tailwind. Override any --neaps-* + * variable to customize the theme. */ :root { - --neaps-primary: #2563eb; /* blue-600 */ - --neaps-secondary: #f59e0b; /* amber-500 */ - --neaps-high: #3b82f6; /* blue-500 */ - --neaps-low: #f59e0b; /* amber-500 */ - --neaps-bg: #ffffff; /* white */ - --neaps-bg-subtle: #f8fafc; /* slate-50 */ - --neaps-text: #0f172a; /* slate-900 */ - --neaps-text-muted: #64748b; /* slate-500 */ - --neaps-border: #e2e8f0; /* slate-200 */ + --neaps-primary: var(--color-sky-600, #0284c7); + --neaps-secondary: var(--color-violet-600, #7c3aed); + --neaps-high: var(--color-teal-600, #0d9488); + --neaps-low: var(--color-amber-600, #d97706); + --neaps-danger: var(--color-red-500, #ef4444); + --neaps-bg: var(--color-white, #ffffff); + --neaps-bg-subtle: var(--color-slate-50, #f8fafc); + --neaps-text: var(--color-slate-900, #0f172a); + --neaps-text-muted: var(--color-slate-500, #64748b); + --neaps-border: var(--color-slate-200, #e2e8f0); + --neaps-night: var(--color-slate-100, #eef2f6); } .dark { - --neaps-primary: #60a5fa; /* blue-400 */ - --neaps-secondary: #fbbf24; /* amber-400 */ - --neaps-high: #60a5fa; /* blue-400 */ - --neaps-low: #fbbf24; /* amber-400 */ - --neaps-bg: #0f172a; /* slate-900 */ - --neaps-bg-subtle: #1e293b; /* slate-800 */ - --neaps-text: #f1f5f9; /* slate-100 */ - --neaps-text-muted: #94a3b8; /* slate-400 */ - --neaps-border: #334155; /* slate-700 */ + --neaps-primary: var(--color-sky-400, #38bdf8); + --neaps-secondary: var(--color-violet-400, #a78bfa); + --neaps-high: var(--color-teal-400, #2dd4bf); + --neaps-low: var(--color-amber-400, #fbbf24); + --neaps-danger: var(--color-red-400, #f87171); + --neaps-bg: var(--color-slate-900, #0f172a); + --neaps-bg-subtle: var(--color-slate-800, #1e293b); + --neaps-text: var(--color-slate-100, #f1f5f9); + --neaps-text-muted: var(--color-slate-400, #94a3b8); + --neaps-border: var(--color-slate-700, #334155); + --neaps-night: var(--color-slate-950, #020617); +} + +/* Hide scrollbar while keeping scroll functional */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; } /* Map marker — used by MapLibre GL, not styleable via Tailwind */ diff --git a/packages/react/src/utils/format.ts b/packages/react/src/utils/format.ts index 51889eb4..6bf5208e 100644 --- a/packages/react/src/utils/format.ts +++ b/packages/react/src/utils/format.ts @@ -8,8 +8,8 @@ export function formatLevel(level: number, units: Units): string { } /** Format a time string in the station's timezone. */ -export function formatTime(isoTime: string, timezone: string): string { - return new Date(isoTime).toLocaleTimeString("en-US", { +export function formatTime(isoTime: string, timezone: string, locale?: string): string { + return new Date(isoTime).toLocaleTimeString(locale, { timeZone: timezone, hour: "numeric", minute: "2-digit", @@ -17,8 +17,8 @@ export function formatTime(isoTime: string, timezone: string): string { } /** Format a date string in the station's timezone. */ -export function formatDate(isoTime: string, timezone: string): string { - return new Date(isoTime).toLocaleDateString("en-US", { +export function formatDate(isoTime: string, timezone: string, locale?: string): string { + return new Date(isoTime).toLocaleDateString(locale, { timeZone: timezone, weekday: "short", month: "short", diff --git a/packages/react/src/utils/scales.ts b/packages/react/src/utils/scales.ts new file mode 100644 index 00000000..93a4d8bc --- /dev/null +++ b/packages/react/src/utils/scales.ts @@ -0,0 +1,61 @@ +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) => new Date(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]); +} diff --git a/packages/react/src/utils/sun.ts b/packages/react/src/utils/sun.ts new file mode 100644 index 00000000..f224a122 --- /dev/null +++ b/packages/react/src/utils/sun.ts @@ -0,0 +1,60 @@ +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 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/tsdown.config.ts b/packages/react/tsdown.config.ts index 7633e0c7..deb3ab8f 100644 --- a/packages/react/tsdown.config.ts +++ b/packages/react/tsdown.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ format: ["cjs", "esm"], sourcemap: true, target: "es2020", - platform: "neutral", + platform: "browser", external: [ "react", "react-dom", From 012e2ccdb679f41228521994e809c4be02e075e6 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 28 Feb 2026 09:43:27 -0500 Subject: [PATCH 09/48] Fix tsc errors --- packages/react/src/components/StationsMap.tsx | 4 ++-- packages/react/src/components/TideConditions.tsx | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index 620651a3..38cd85ea 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -210,7 +210,7 @@ export const StationsMap = forwardRef(function Station type: props.type ?? "reference", }; - if (popupContent === "preview" ? viewState.zoom >= 10 : popupContent !== false) { + if (popupContent === "preview" ? (viewState.zoom ?? 0) >= 10 : popupContent !== false) { setSelectedStation(station); } @@ -227,7 +227,7 @@ export const StationsMap = forwardRef(function Station ...prev, longitude: pos.coords.longitude, latitude: pos.coords.latitude, - zoom: Math.max(prev.zoom, 10), + zoom: Math.max(prev.zoom ?? 0, 10), })); }, () => { diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx index bdcc20f9..815d6b8c 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -95,13 +95,7 @@ export function TideConditions({ return (
    - +

    {new Date(currentLevel.time).toLocaleString(locale, { From 6e36fa2180de22732d80f06fa20beb9cb5974274 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 28 Feb 2026 09:46:59 -0500 Subject: [PATCH 10/48] Polyfill intersection observer --- packages/react/package.json | 1 + packages/react/test/setup.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/react/package.json b/packages/react/package.json index dfc098aa..66306e1b 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -75,6 +75,7 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "intersection-observer": "^0.12.2", "jsdom": "^28.1.0", "maplibre-gl": "^4.0.0", "react": "^19.0.0", diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts index c67e6ea2..6bc88aee 100644 --- a/packages/react/test/setup.ts +++ b/packages/react/test/setup.ts @@ -4,6 +4,9 @@ import ResizeObserver from "resize-observer-polyfill"; global.ResizeObserver = ResizeObserver; +// jsdom doesn't provide IntersectionObserver +import "intersection-observer"; + // jsdom doesn't provide matchMedia — stub it for useDarkMode / useThemeColors Object.defineProperty(window, "matchMedia", { writable: true, From ead08c80a329111fd2000577a57f7e2fc5c8c3d9 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 28 Feb 2026 15:26:04 -0500 Subject: [PATCH 11/48] Fix tests --- packages/react/package.json | 1 - .../react/test/components/TideGraph.test.tsx | 4 ++-- .../test/components/TideStation.test.tsx | 2 +- .../test/integration/TideStation.test.tsx | 4 ++-- packages/react/test/provider.test.tsx | 1 + packages/react/test/setup.ts | 21 +++++++++++++++++-- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/react/package.json b/packages/react/package.json index 66306e1b..4d5488c6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -81,7 +81,6 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-map-gl": "^7.0.0", - "resize-observer-polyfill": "^1.5.1", "storybook": "^10.2.10", "tailwindcss": "^4.2.0", "vitest-axe": "^0.1.0" diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 3757ba0e..5c3dac87 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -26,7 +26,7 @@ describe("TideGraph", () => { { wrapper: createTestWrapper() }, ); - expect(container.querySelector("canvas")).not.toBeNull(); + expect(container.querySelector("svg")).not.toBeNull(); }); test("renders with empty extremes", () => { @@ -34,7 +34,7 @@ describe("TideGraph", () => { wrapper: createTestWrapper(), }); - expect(container.querySelector("canvas")).not.toBeNull(); + expect(container.querySelector("svg")).not.toBeNull(); }); test("applies className", () => { diff --git a/packages/react/test/components/TideStation.test.tsx b/packages/react/test/components/TideStation.test.tsx index 4a02eda4..2d31784f 100644 --- a/packages/react/test/components/TideStation.test.tsx +++ b/packages/react/test/components/TideStation.test.tsx @@ -27,7 +27,7 @@ describe("TideStation", () => { ); // Station name should be in an h3 - const heading = view.getByRole("heading", { level: 3 }); + const heading = view.getByRole("heading", { level: 1 }); expect(heading.textContent!.length).toBeGreaterThan(0); }); diff --git a/packages/react/test/integration/TideStation.test.tsx b/packages/react/test/integration/TideStation.test.tsx index 1556ebf9..d6a40e7f 100644 --- a/packages/react/test/integration/TideStation.test.tsx +++ b/packages/react/test/integration/TideStation.test.tsx @@ -19,7 +19,7 @@ describe("TideStation integration", () => { { timeout: 10000 }, ); - expect(view.getByRole("heading", { level: 3 })).toBeDefined(); + expect(view.getByRole("heading", { level: 1 })).toBeDefined(); }); test("renders graph by default", async () => { @@ -66,6 +66,6 @@ describe("TideStation integration", () => { ); // Should show an error message instead of station content - expect(view.queryByRole("heading", { level: 3 })).toBeNull(); + expect(view.queryByRole("heading", { level: 1 })).toBeNull(); }); }); diff --git a/packages/react/test/provider.test.tsx b/packages/react/test/provider.test.tsx index 2f19ff26..ea64343a 100644 --- a/packages/react/test/provider.test.tsx +++ b/packages/react/test/provider.test.tsx @@ -19,6 +19,7 @@ describe("NeapsProvider", () => { baseUrl: "https://api.example.com", units: "feet", datum: "MLLW", + locale: "en-US", }); }); diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts index 6bc88aee..1560cd69 100644 --- a/packages/react/test/setup.ts +++ b/packages/react/test/setup.ts @@ -1,8 +1,25 @@ import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; -import ResizeObserver from "resize-observer-polyfill"; -global.ResizeObserver = ResizeObserver; +// jsdom has no layout engine, so ResizeObserver never reports a non-zero width. +// Mock it to immediately report a width so components that depend on it render. +global.ResizeObserver = class FakeResizeObserver implements ResizeObserver { + constructor(private cb: ResizeObserverCallback) {} + observe(target: Element) { + this.cb( + [{ target, contentRect: { width: 600, height: 300 } } as unknown as ResizeObserverEntry], + this as unknown as ResizeObserver, + ); + } + unobserve() {} + disconnect() {} +}; + +// jsdom doesn't implement SVG text measurement methods (used by @visx/text) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(SVGElement.prototype as any).getComputedTextLength = function () { + return 0; +}; // jsdom doesn't provide IntersectionObserver import "intersection-observer"; From 5945e6bcf7b2a44673a84a475436d9a32e0811f4 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 28 Feb 2026 15:48:18 -0500 Subject: [PATCH 12/48] Publish packages to pkg.pr.new --- .github/workflows/test.yml | 4 ++++ package.json | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6da16a0c..c1c356e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,7 @@ jobs: runs-on: ubuntu-latest permissions: id-token: write + pull-requests: write steps: - uses: actions/checkout@v6 @@ -53,6 +54,9 @@ jobs: - name: Test build run: npm run build + - name: Publish to pkg.pr.new + run: npx pkg-pr-new publish ./packages/* + - name: Pack all packages run: npm pack --workspaces diff --git a/package.json b/package.json index 7f327706..d651a9f4 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.20.1", "typescript": "^5.3.3", From 541b05da054264b35d82870c9c26e8de0097e290 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 07:54:58 -0500 Subject: [PATCH 13/48] Switch to browser testing --- .github/workflows/test.yml | 3 ++ packages/react/package.json | 7 ++- packages/react/test/a11y.test.tsx | 43 ++++++++----------- .../test/components/StationSearch.test.tsx | 27 ++---------- .../react/test/components/TideGraph.test.tsx | 14 +++--- .../test/integration/StationSearch.test.tsx | 21 +-------- packages/react/test/setup.ts | 38 ---------------- packages/react/test/vitest-axe.d.ts | 12 ------ packages/react/vitest.config.ts | 7 ++- 9 files changed, 44 insertions(+), 128 deletions(-) delete mode 100644 packages/react/test/vitest-axe.d.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1c356e9..3d93fb76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,6 +51,9 @@ jobs: - name: Install modules run: npm install + - name: Install Playwright browsers + run: npx playwright install chromium + - name: Test build run: npm run build diff --git a/packages/react/package.json b/packages/react/package.json index 4d5488c6..e0b2cb95 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -75,14 +75,13 @@ "@testing-library/user-event": "^14.6.1", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "intersection-observer": "^0.12.2", - "jsdom": "^28.1.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", - "vitest-axe": "^0.1.0" + "tailwindcss": "^4.2.0" } } diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx index c6b217f9..c3eaddf9 100644 --- a/packages/react/test/a11y.test.tsx +++ b/packages/react/test/a11y.test.tsx @@ -1,7 +1,6 @@ import { describe, test, expect } from "vitest"; import { render, within, waitFor } from "@testing-library/react"; -import { configureAxe } from "vitest-axe"; -import * as axeMatchers from "vitest-axe/matchers"; +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"; @@ -9,18 +8,17 @@ import { TideStation } from "../src/components/TideStation.js"; import { TideGraph } from "../src/components/TideGraph.js"; import { createTestWrapper } from "./helpers.js"; -const axe = configureAxe({ - globalOptions: { - checks: [ - { - id: "color-contrast", - enabled: false, - }, - ], - }, -}); - -expect.extend(axeMatchers); +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"; @@ -36,8 +34,7 @@ describe("accessibility", () => { wrapper: createTestWrapper(), }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await checkA11y(container); }); test("StationSearch has no violations", async () => { @@ -45,8 +42,7 @@ describe("accessibility", () => { wrapper: createTestWrapper(), }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await checkA11y(container); }); test("NearbyStations has no violations after loading", async () => { @@ -62,11 +58,10 @@ describe("accessibility", () => { { timeout: 10000 }, ); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await checkA11y(container); }); - test("TideStation has no violations after loading", async () => { + test("TideStation has no violations after loading", { timeout: 15000 }, async () => { const { container } = render(, { wrapper: createTestWrapper() }); const view = within(container); @@ -77,8 +72,7 @@ describe("accessibility", () => { { timeout: 10000 }, ); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await checkA11y(container); }); test("TideGraph with data has no violations", async () => { @@ -93,7 +87,6 @@ describe("accessibility", () => { wrapper: createTestWrapper(), }); - const results = await axe(container); - expect(results).toHaveNoViolations(); + await checkA11y(container); }); }); diff --git a/packages/react/test/components/StationSearch.test.tsx b/packages/react/test/components/StationSearch.test.tsx index 0ff7c6e6..d2fb6bb7 100644 --- a/packages/react/test/components/StationSearch.test.tsx +++ b/packages/react/test/components/StationSearch.test.tsx @@ -4,28 +4,9 @@ import userEvent from "@testing-library/user-event"; import { StationSearch } from "../../src/components/StationSearch.js"; import { createTestWrapper } from "../helpers.js"; -// Node 22's global localStorage doesn't implement Web Storage API -const store = new Map(); -Object.defineProperty(window, "localStorage", { - value: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, String(value)), - removeItem: (key: string) => { - store.delete(key); - }, - clear: () => store.clear(), - get length() { - return store.size; - }, - key: (i: number) => [...store.keys()][i] ?? null, - }, - writable: true, - configurable: true, -}); - describe("StationSearch", () => { beforeEach(() => { - store.clear(); + localStorage.clear(); }); test("renders input with default placeholder", () => { @@ -75,7 +56,7 @@ describe("StationSearch", () => { const user = userEvent.setup(); // Seed recent searches so dropdown opens on focus - store.set( + localStorage.setItem( "neaps-recent-searches", JSON.stringify([{ id: "noaa/8443970", name: "Boston", region: "MA", country: "US" }]), ); @@ -99,7 +80,7 @@ describe("StationSearch", () => { const user = userEvent.setup(); // Seed recent searches - store.set( + localStorage.setItem( "neaps-recent-searches", JSON.stringify([{ id: "noaa/8443970", name: "Boston", region: "MA", country: "US" }]), ); @@ -116,7 +97,7 @@ describe("StationSearch", () => { const option = view.getAllByRole("option")[0]; await user.click(option); - const recent = JSON.parse(store.get("neaps-recent-searches") ?? "[]"); + const recent = JSON.parse(localStorage.getItem("neaps-recent-searches") ?? "[]"); expect(recent.length).toBeGreaterThan(0); }); }); diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 5c3dac87..09d63f6d 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { render } from "@testing-library/react"; +import { render, waitFor } from "@testing-library/react"; import { TideGraph } from "../../src/components/TideGraph.js"; import { createTestWrapper } from "../helpers.js"; @@ -20,21 +20,25 @@ const extremes = [ ]; describe("TideGraph", () => { - test("renders a canvas element in data-driven mode", () => { + test("renders a canvas element in data-driven mode", async () => { const { container } = render( , { wrapper: createTestWrapper() }, ); - expect(container.querySelector("svg")).not.toBeNull(); + await waitFor(() => { + expect(container.querySelector("svg")).not.toBeNull(); + }); }); - test("renders with empty extremes", () => { + test("renders with empty extremes", async () => { const { container } = render(, { wrapper: createTestWrapper(), }); - expect(container.querySelector("svg")).not.toBeNull(); + await waitFor(() => { + expect(container.querySelector("svg")).not.toBeNull(); + }); }); test("applies className", () => { diff --git a/packages/react/test/integration/StationSearch.test.tsx b/packages/react/test/integration/StationSearch.test.tsx index f7b4d9c6..54e1fdfb 100644 --- a/packages/react/test/integration/StationSearch.test.tsx +++ b/packages/react/test/integration/StationSearch.test.tsx @@ -4,27 +4,8 @@ import userEvent from "@testing-library/user-event"; import { StationSearch } from "../../src/components/StationSearch.js"; import { createTestWrapper } from "../helpers.js"; -// Node 22's global localStorage doesn't implement Web Storage API -const store = new Map(); -Object.defineProperty(window, "localStorage", { - value: { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => store.set(key, String(value)), - removeItem: (key: string) => { - store.delete(key); - }, - clear: () => store.clear(), - get length() { - return store.size; - }, - key: (i: number) => [...store.keys()][i] ?? null, - }, - writable: true, - configurable: true, -}); - describe("StationSearch integration", () => { - beforeEach(() => store.clear()); + beforeEach(() => localStorage.clear()); test("renders search input", () => { const { container } = render(, { diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts index 1560cd69..c10eddee 100644 --- a/packages/react/test/setup.ts +++ b/packages/react/test/setup.ts @@ -1,44 +1,6 @@ import { afterEach } from "vitest"; import { cleanup } from "@testing-library/react"; -// jsdom has no layout engine, so ResizeObserver never reports a non-zero width. -// Mock it to immediately report a width so components that depend on it render. -global.ResizeObserver = class FakeResizeObserver implements ResizeObserver { - constructor(private cb: ResizeObserverCallback) {} - observe(target: Element) { - this.cb( - [{ target, contentRect: { width: 600, height: 300 } } as unknown as ResizeObserverEntry], - this as unknown as ResizeObserver, - ); - } - unobserve() {} - disconnect() {} -}; - -// jsdom doesn't implement SVG text measurement methods (used by @visx/text) -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(SVGElement.prototype as any).getComputedTextLength = function () { - return 0; -}; - -// jsdom doesn't provide IntersectionObserver -import "intersection-observer"; - -// jsdom doesn't provide matchMedia — stub it for useDarkMode / useThemeColors -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => {}, - removeListener: () => {}, - addEventListener: () => {}, - removeEventListener: () => {}, - dispatchEvent: () => false, - }), -}); - afterEach(() => { cleanup(); }); diff --git a/packages/react/test/vitest-axe.d.ts b/packages/react/test/vitest-axe.d.ts deleted file mode 100644 index 534604f3..00000000 --- a/packages/react/test/vitest-axe.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import "vitest"; - -interface AxeMatchers { - toHaveNoViolations(): void; -} - -declare module "vitest" { - // eslint-disable-next-line - interface Assertion extends AxeMatchers {} - // eslint-disable-next-line - interface AsymmetricMatchersContaining extends AxeMatchers {} -} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 08046e23..31f19e8b 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -1,8 +1,13 @@ import { defineProject } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; export default defineProject({ test: { - environment: "jsdom", + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: "chromium" }], + }, setupFiles: ["./test/setup.ts"], globalSetup: ["./test/globalSetup.ts"], }, From 490d675415d5ef190b268743470a02a1590474f6 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 09:08:18 -0500 Subject: [PATCH 14/48] Tweak graphs, Add unit/datum/timezone settings --- .../react/src/components/TideConditions.tsx | 10 +- .../react/src/components/TideCycleGraph.tsx | 7 +- packages/react/src/components/TideGraph.tsx | 70 ++++----- .../react/src/components/TideSettings.tsx | 139 ++++++++++++++++++ packages/react/src/components/TideStation.tsx | 8 +- packages/react/src/components/TideTable.tsx | 7 - packages/react/src/hooks/use-tide-chunks.ts | 4 +- packages/react/src/index.ts | 5 +- packages/react/src/provider.tsx | 90 ++++++++++-- packages/react/src/styles.css | 22 +++ 10 files changed, 285 insertions(+), 77 deletions(-) create mode 100644 packages/react/src/components/TideSettings.tsx diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx index 815d6b8c..1ece0a1e 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -63,7 +63,7 @@ export function WaterLevelAtTime({ )} - + {new Date(time).toLocaleString(locale, { timeStyle: "short", })} @@ -94,7 +94,7 @@ export function TideConditions({ return (
    -
    +

    @@ -104,7 +104,7 @@ export function TideConditions({ })}

    -
    +
    - {nextExtreme && ( + {nextExtreme ? ( - )} + ) :
    }
    diff --git a/packages/react/src/components/TideCycleGraph.tsx b/packages/react/src/components/TideCycleGraph.tsx index 9db8f092..e50a9a6f 100644 --- a/packages/react/src/components/TideCycleGraph.tsx +++ b/packages/react/src/components/TideCycleGraph.tsx @@ -57,9 +57,10 @@ function TideCycleGraphChart({ x2={innerW} y1={yScale(0)} y2={yScale(0)} - stroke="var(--neaps-text-muted)" - strokeWidth={1} - strokeOpacity={0.3} + stroke="var(--neaps-primary)" + strokeWidth={1.5} + strokeDasharray="1, 3" + strokeOpacity={0.75} /> {/* Area fill: positive (above zero) */} @@ -284,16 +285,16 @@ function TideGraphChart({ stroke="var(--neaps-bg)" strokeWidth={2} /> - + {e.high ? ( <> - + {formatTime(e.time, timezone, locale)} @@ -312,20 +313,25 @@ function TideGraphChart({ ) : ( <> - + {formatLevel(e.level, units)} - + {formatTime(e.time, timezone, locale)} @@ -377,19 +383,6 @@ function TideGraphChart({ ); })()} - {/* Datum label */} - {datum && ( - - {datum} - - )} - {/* Tooltip hit area */} - - + + - {datum && ( - - {datum} - - )}
    diff --git a/packages/react/src/components/TideSettings.tsx b/packages/react/src/components/TideSettings.tsx new file mode 100644 index 00000000..f98d4c6f --- /dev/null +++ b/packages/react/src/components/TideSettings.tsx @@ -0,0 +1,139 @@ +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.tsx b/packages/react/src/components/TideStation.tsx index 9566fe39..fdc5c4cd 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -10,6 +10,7 @@ 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"; export interface TideStationProps { id: string; @@ -63,6 +64,7 @@ export function TideStation({ const s = station.data!; const units: Units = timeline.data?.units ?? config.units; const datum = timeline.data?.datum ?? extremes.data?.datum; + const timezone = config.timezone ?? s.timezone; const timelineData = timeline.data?.timeline ?? []; const extremesData = extremes.data?.extremes ?? []; @@ -76,14 +78,16 @@ export function TideStation({ timeline={timelineData} extremes={extremesData} units={units} - timezone={s.timezone} + timezone={timezone} /> {showGraph && } {showTable && ( - + )} + +
    ); } diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx index cdddf6ad..664b48f8 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -123,13 +123,6 @@ function TideTableView({ )}

    + Date + Time + Level @@ -86,25 +90,30 @@ function TideTableView({ {i === 0 ? ( {group.label} - {formatTime(extreme.time, timezone)} + + {formatTime(extreme.time, timezone, locale)} + - {formatLevel(extreme.level, units)} + + + {formatLevel(extreme.level, units)} + + {extreme.label}
    - {(datum || (timezone && timezone !== "UTC")) && ( -
    - {datum && Datum: {datum}} - {datum && timezone && · } - {timezone && {timezone}} -
    - )}
    ); } diff --git a/packages/react/src/hooks/use-tide-chunks.ts b/packages/react/src/hooks/use-tide-chunks.ts index b929fcba..7e649e84 100644 --- a/packages/react/src/hooks/use-tide-chunks.ts +++ b/packages/react/src/hooks/use-tide-chunks.ts @@ -49,7 +49,7 @@ export interface UseTideChunksReturn { } export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn { - const { baseUrl, units, datum } = useNeapsConfig(); + const { baseUrl, units, datum, timezone } = useNeapsConfig(); const [chunks, setChunks] = useState(getInitialChunks); const yDomainRef = useRef<[number, number] | null>(null); @@ -172,7 +172,7 @@ export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn isLoading, error: error as Error | null, station, - timezone: station?.timezone ?? "UTC", + timezone: timezone ?? station?.timezone ?? "UTC", units: firstTimeline?.data?.units ?? units, datum: firstTimeline?.data?.datum ?? firstExtremes?.data?.datum, }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index eb67e1cd..8d01b667 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,6 +1,6 @@ // Provider -export { NeapsProvider, useNeapsConfig } from "./provider.js"; -export type { NeapsProviderProps, NeapsConfig } from "./provider.js"; +export { NeapsProvider, useNeapsConfig, useUpdateConfig } from "./provider.js"; +export type { NeapsProviderProps, NeapsConfig, NeapsConfigUpdater } from "./provider.js"; // Hooks export { useStation } from "./hooks/use-station.js"; @@ -22,6 +22,7 @@ export * from "./components/TideCycleGraph.js"; export * from "./components/TideGraph.js"; export * from "./components/TideTable.js"; export * from "./components/StationDisclaimers.js"; +export * from "./components/TideSettings.js"; export * from "./components/StationSearch.js"; export * from "./components/NearbyStations.js"; export * from "./components/StationsMap.js"; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 0da7a237..a7cc25c5 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,16 +1,53 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Units } from "./types.js"; +const IMPERIAL_LOCALES = ["en-US", "en-LR", "my-MM"]; + +const defaultLocale = typeof navigator !== "undefined" ? navigator.language : "en-US"; +const defaultUnits: Units = IMPERIAL_LOCALES.includes(defaultLocale) ? "feet" : "meters"; + export interface NeapsConfig { baseUrl: string; units: Units; datum?: string; + timezone?: string; locale: string; } -const NeapsContext = createContext(null); +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.) + } +} let defaultQueryClient: QueryClient | null = null; @@ -32,6 +69,7 @@ export interface NeapsProviderProps { baseUrl: string; units?: Units; datum?: string; + timezone?: string; locale?: string; queryClient?: QueryClient; children: ReactNode; @@ -39,19 +77,41 @@ export interface NeapsProviderProps { export function NeapsProvider({ baseUrl, - units = "meters", - datum, - locale = typeof navigator !== "undefined" ? navigator.language : "en-US", + units: initialUnits = defaultUnits, + datum: initialDatum, + timezone: initialTimezone, + locale: initialLocale = defaultLocale, queryClient, children, }: NeapsProviderProps) { + const [overrides, setOverrides] = useState(loadSettings); + const config = useMemo( - () => ({ baseUrl, units, datum, locale }), - [baseUrl, units, datum, locale], + () => ({ + 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} @@ -60,9 +120,17 @@ export function NeapsProvider({ } export function useNeapsConfig(): NeapsConfig { - const config = useContext(NeapsContext); - if (!config) { + const ctx = useContext(NeapsContext); + if (!ctx) { throw new Error("useNeapsConfig must be used within a "); } - return config; + 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/styles.css b/packages/react/src/styles.css index 98de452c..b8baf8db 100644 --- a/packages/react/src/styles.css +++ b/packages/react/src/styles.css @@ -42,6 +42,28 @@ 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; From c13e109d7311db644d770b052779b9c90c36a485 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 09:12:39 -0500 Subject: [PATCH 15/48] Limit height of table --- packages/react/src/components/TideTable.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx index 664b48f8..e0d8c5de 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -56,10 +56,10 @@ function TideTableView({ return (
    - + {grouped.map((group) => group.extremes.map((extreme, i) => { - const isNext = !foundNext && new Date(extreme.time) > now; + const isNext = !foundNext && extreme.time > now; if (isNext) foundNext = true; return ( diff --git a/packages/react/src/hooks/use-current-level.ts b/packages/react/src/hooks/use-current-level.ts index aeb8efba..65086cc1 100644 --- a/packages/react/src/hooks/use-current-level.ts +++ b/packages/react/src/hooks/use-current-level.ts @@ -7,7 +7,7 @@ export function interpolateLevel(timeline: TimelineEntry[], at: number): Timelin let lo = -1; let hi = -1; for (let i = 0; i < timeline.length; i++) { - if (new Date(timeline[i].time).getTime() <= at) lo = i; + if (timeline[i].time.getTime() <= at) lo = i; else if (hi === -1) { hi = i; break; @@ -16,12 +16,12 @@ export function interpolateLevel(timeline: TimelineEntry[], at: number): Timelin if (lo === -1 || hi === -1) return null; - const t0 = new Date(timeline[lo].time).getTime(); - const t1 = new Date(timeline[hi].time).getTime(); + 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).toISOString(), level }; + return { time: new Date(at), level }; } /** diff --git a/packages/react/src/hooks/use-tide-chunks.ts b/packages/react/src/hooks/use-tide-chunks.ts index 7e649e84..0718a898 100644 --- a/packages/react/src/hooks/use-tide-chunks.ts +++ b/packages/react/src/hooks/use-tide-chunks.ts @@ -72,34 +72,36 @@ export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn }); const timeline = useMemo(() => { - const seen = new Set(); + const seen = new Set(); const result: TimelineEntry[] = []; for (const q of timelineQueries) { if (!q.data) continue; for (const entry of q.data.timeline) { - if (!seen.has(entry.time)) { - seen.add(entry.time); + const ms = entry.time.getTime(); + if (!seen.has(ms)) { + seen.add(ms); result.push(entry); } } } - result.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + result.sort((a, b) => a.time.getTime() - b.time.getTime()); return result; }, [timelineQueries]); const extremes = useMemo(() => { - const seen = new Set(); + const seen = new Set(); const result: Extreme[] = []; for (const q of extremesQueries) { if (!q.data) continue; for (const entry of q.data.extremes) { - if (!seen.has(entry.time)) { - seen.add(entry.time); + const ms = entry.time.getTime(); + if (!seen.has(ms)) { + seen.add(ms); result.push(entry); } } } - result.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); + result.sort((a, b) => a.time.getTime() - b.time.getTime()); return result; }, [extremesQueries]); diff --git a/packages/react/src/hooks/use-tide-scales.ts b/packages/react/src/hooks/use-tide-scales.ts index 4b3656ac..0ff67bc3 100644 --- a/packages/react/src/hooks/use-tide-scales.ts +++ b/packages/react/src/hooks/use-tide-scales.ts @@ -30,7 +30,7 @@ export function useTideScales({ 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) => new Date(d.time).getTime()); + 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); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index c5405953..5b1e704e 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,54 +1,27 @@ export type Units = "meters" | "feet"; +import type { Station as BaseStation } from "@neaps/tide-database"; -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 BaseStation { + defaultDatum: string; } -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 type StationSummary = Pick< + Station, + | "id" + | "name" + | "latitude" + | "longitude" + | "region" + | "country" + | "continent" + | "timezone" + | "type" +> & { + distance?: number; +}; export interface Extreme { - time: string; + time: Date; level: number; high: boolean; low: boolean; @@ -56,7 +29,7 @@ export interface Extreme { } export interface TimelineEntry { - time: string; + time: Date; level: number; } diff --git a/packages/react/src/utils/format.ts b/packages/react/src/utils/format.ts index 6bf5208e..98c55bcb 100644 --- a/packages/react/src/utils/format.ts +++ b/packages/react/src/utils/format.ts @@ -7,18 +7,18 @@ export function formatLevel(level: number, units: Units): string { return `${level.toFixed(precision)} ${suffix}`; } -/** Format a time string in the station's timezone. */ -export function formatTime(isoTime: string, timezone: string, locale?: string): string { - return new Date(isoTime).toLocaleTimeString(locale, { +/** 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 string in the station's timezone. */ -export function formatDate(isoTime: string, timezone: string, locale?: string): string { - return new Date(isoTime).toLocaleDateString(locale, { +/** 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", @@ -36,6 +36,6 @@ export function formatDistance(meters: number, units: Units): string { } /** Get a date key (YYYY-MM-DD) in the station's timezone. */ -export function getDateKey(isoTime: string, timezone: string): string { - return new Date(isoTime).toLocaleDateString("en-CA", { timeZone: timezone }); +export function getDateKey(time: Date, timezone: string): string { + return time.toLocaleDateString("en-CA", { timeZone: timezone }); } diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx index 1810af09..49003b27 100644 --- a/packages/react/test/a11y.test.tsx +++ b/packages/react/test/a11y.test.tsx @@ -25,9 +25,9 @@ const STATION_ID = "noaa/8443970"; describe("accessibility", () => { test("TideTable with data has no violations", async () => { const extremes = [ - { time: "2025-12-17T04:30:00Z", level: 1.5, high: true, low: false, label: "High" }, - { time: "2025-12-17T10:45:00Z", level: 0.2, high: false, low: true, label: "Low" }, - { time: "2025-12-17T16:00:00Z", level: 1.4, high: true, low: false, label: "High" }, + { 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(, { @@ -77,10 +77,10 @@ describe("accessibility", () => { test("TideGraph with data has no violations", async () => { const timeline = [ - { time: "2025-12-17T00:00:00Z", level: 0.5 }, - { time: "2025-12-17T06:00:00Z", level: 1.5 }, - { time: "2025-12-17T12:00:00Z", level: 0.3 }, - { time: "2025-12-17T18:00:00Z", level: 1.4 }, + { 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(, { diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 4f9256c6..31bdd1ca 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -4,19 +4,19 @@ import { TideGraph } from "../../src/components/TideGraph/index.js"; import { createTestWrapper } from "../helpers.js"; const timeline = [ - { time: "2025-12-17T00:00:00Z", level: 0.5 }, - { time: "2025-12-17T03:00:00Z", level: 1.2 }, - { time: "2025-12-17T06:00:00Z", level: 1.5 }, - { time: "2025-12-17T09:00:00Z", level: 0.8 }, - { time: "2025-12-17T12:00:00Z", level: 0.3 }, - { time: "2025-12-17T15:00:00Z", level: 0.9 }, - { time: "2025-12-17T18:00:00Z", level: 1.4 }, - { time: "2025-12-17T21:00:00Z", level: 0.7 }, + { 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: "2025-12-17T06:00:00Z", level: 1.5, high: true, low: false, label: "High" }, - { time: "2025-12-17T12:00:00Z", level: 0.3, high: false, low: true, label: "Low" }, + { 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" }, ]; describe("TideGraph", () => { diff --git a/packages/react/test/components/TideTable.test.tsx b/packages/react/test/components/TideTable.test.tsx index 57db1933..4a7cb242 100644 --- a/packages/react/test/components/TideTable.test.tsx +++ b/packages/react/test/components/TideTable.test.tsx @@ -4,10 +4,10 @@ import { TideTable } from "../../src/components/TideTable.js"; import { createTestWrapper } from "../helpers.js"; const extremes = [ - { time: "2025-12-17T04:30:00Z", level: 1.5, high: true, low: false, label: "High" }, - { time: "2025-12-17T10:45:00Z", level: 0.2, high: false, low: true, label: "Low" }, - { time: "2025-12-17T16:00:00Z", level: 1.4, high: true, low: false, label: "High" }, - { time: "2025-12-17T22:15:00Z", level: 0.3, high: false, low: true, label: "Low" }, + { 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", () => { diff --git a/packages/react/test/format.test.ts b/packages/react/test/format.test.ts index 3f01a57d..df388643 100644 --- a/packages/react/test/format.test.ts +++ b/packages/react/test/format.test.ts @@ -27,19 +27,19 @@ describe("formatLevel", () => { describe("formatTime", () => { test("formats time in given timezone", () => { - const result = formatTime("2025-12-17T10:23:00Z", "America/New_York"); + 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("2025-12-17T10:23:00Z", "UTC"); + 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("2025-12-17T10:00:00Z", "UTC"); + const result = formatDate(new Date("2025-12-17T10:00:00Z"), "UTC"); expect(result).toBe("Wed, Dec 17"); }); }); @@ -65,7 +65,7 @@ describe("formatDistance", () => { 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("2025-12-17T02:00:00Z", "America/New_York")).toBe("2025-12-16"); - expect(getDateKey("2025-12-17T02:00:00Z", "UTC")).toBe("2025-12-17"); + 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/hooks/use-timeline.test.tsx b/packages/react/test/hooks/use-timeline.test.tsx index 3404832e..ea348481 100644 --- a/packages/react/test/hooks/use-timeline.test.tsx +++ b/packages/react/test/hooks/use-timeline.test.tsx @@ -43,7 +43,7 @@ describe("useTimeline", () => { ); const entry = result.current.data!.timeline[0]; - expect(entry.time).toBeTypeOf("string"); + expect(entry.time).toBeInstanceOf(Date); expect(entry.level).toBeTypeOf("number"); }); From 7984fc81ac7b827cd94a4c9c508dffd175fa8e62 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 07:11:16 -0500 Subject: [PATCH 21/48] Set browser locale --- packages/react/test/provider.test.tsx | 5 +++-- packages/react/vitest.config.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/react/test/provider.test.tsx b/packages/react/test/provider.test.tsx index ea64343a..c49a5ca8 100644 --- a/packages/react/test/provider.test.tsx +++ b/packages/react/test/provider.test.tsx @@ -23,14 +23,15 @@ describe("NeapsProvider", () => { }); }); - test("defaults units to meters", () => { + test("defaults units based on locale", () => { const minimalWrapper = ({ children }: { children: ReactNode }) => ( {children} ); const { result } = renderHook(() => useNeapsConfig(), { wrapper: minimalWrapper }); - expect(result.current.units).toBe("meters"); + // en-US defaults to feet; non-US locales default to meters + expect(result.current.units).toBe("feet"); expect(result.current.datum).toBeUndefined(); }); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 31f19e8b..5ea463a7 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -5,8 +5,14 @@ export default defineProject({ test: { browser: { enabled: true, - provider: playwright(), - instances: [{ browser: "chromium" }], + provider: playwright({ contextOptions: { locale: "en-US" } }), + instances: [ + { + browser: "chromium", + headless: true, + screenshotFailures: true, + }, + ], }, setupFiles: ["./test/setup.ts"], globalSetup: ["./test/globalSetup.ts"], From 21374c4da44cff2fa459ffe50c2a4e6629ee21d8 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 07:25:31 -0500 Subject: [PATCH 22/48] Revert station types to avoid dep --- .../src/components/TideGraph/TideGraph.tsx | 30 +-------- .../components/TideGraph/TideGraphChart.tsx | 4 +- .../components/TideGraph/TideGraphScroll.tsx | 3 - .../components/TideGraph/TideGraphStatic.tsx | 16 +++-- packages/react/src/types.ts | 61 +++++++++++++------ packages/react/test/a11y.test.tsx | 11 ++-- .../react/test/components/TideGraph.test.tsx | 17 +++--- 7 files changed, 76 insertions(+), 66 deletions(-) diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx index 1a14428c..6ae79b1d 100644 --- a/packages/react/src/components/TideGraph/TideGraph.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -1,42 +1,16 @@ import { useNeapsConfig } from "../../provider.js"; import { TideGraphScroll } from "./TideGraphScroll.js"; -import { TideGraphStatic } from "./TideGraphStatic.js"; import { PX_PER_DAY_DEFAULT } from "./constants.js"; -import type { TimelineEntry, Extreme, Units } from "../../types.js"; -export interface TideGraphDataProps { - timeline: TimelineEntry[]; - extremes?: Extreme[]; - timezone?: string; - units?: Units; -} - -export interface TideGraphFetchProps { +export interface TideGraphProps { id: string; - timeline?: undefined; -} - -export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { pxPerDay?: number; className?: string; -}; +} export function TideGraph(props: TideGraphProps) { const config = useNeapsConfig(); - if (props.timeline) { - return ( - - ); - } - return ( ((d) => d.time.getTime()).left; @@ -19,7 +20,6 @@ export function TideGraphChart({ extremes, timezone, units, - locale, svgWidth, yDomainOverride, latitude, @@ -32,7 +32,6 @@ export function TideGraphChart({ extremes: Extreme[]; timezone: string; units: Units; - locale: string; svgWidth: number; yDomainOverride?: [number, number]; latitude?: number; @@ -42,6 +41,7 @@ export function TideGraphChart({ onSelect: (entry: TimelineEntry | null, sticky?: boolean) => void; }) { const gradientId = useId(); + const { locale } = useNeapsConfig(); const { xScale, yScale, innerW, innerH } = useTideScales({ timeline, diff --git a/packages/react/src/components/TideGraph/TideGraphScroll.tsx b/packages/react/src/components/TideGraph/TideGraphScroll.tsx index 4bae3c27..097e9ecb 100644 --- a/packages/react/src/components/TideGraph/TideGraphScroll.tsx +++ b/packages/react/src/components/TideGraph/TideGraphScroll.tsx @@ -12,12 +12,10 @@ import type { TimelineEntry } from "../../types.js"; export function TideGraphScroll({ id, pxPerDay, - locale, className, }: { id: string; pxPerDay: number; - locale: string; className?: string; }) { const scrollRef = useRef(null); @@ -221,7 +219,6 @@ export function TideGraphScroll({ extremes={extremes} timezone={timezone} units={units} - locale={locale} svgWidth={svgWidth} yDomainOverride={yDomain} latitude={station?.latitude} diff --git a/packages/react/src/components/TideGraph/TideGraphStatic.tsx b/packages/react/src/components/TideGraph/TideGraphStatic.tsx index 7abafd0f..634e4147 100644 --- a/packages/react/src/components/TideGraph/TideGraphStatic.tsx +++ b/packages/react/src/components/TideGraph/TideGraphStatic.tsx @@ -4,17 +4,25 @@ import { useTooltip } from "@visx/tooltip"; import { useContainerWidth } from "../../hooks/use-container-width.js"; import { useCurrentLevel } from "../../hooks/use-current-level.js"; import { TideGraphChart } from "./TideGraphChart.js"; -import type { TideGraphDataProps } from "./TideGraph.js"; -import type { TimelineEntry } from "../../types.js"; +import type { TimelineEntry, Extreme, Units } from "../../types.js"; + +export interface TideGraphStaticProps { + timeline: TimelineEntry[]; + extremes?: Extreme[]; + timezone?: string; + units?: Units; + locale?: string; + className?: string; +} export function TideGraphStatic({ timeline, extremes = [], timezone = "UTC", units = "feet", - locale, + locale = "en", className, -}: TideGraphDataProps & { locale: string; className?: string }) { +}: TideGraphStaticProps) { const { ref: containerRef, width: containerWidth } = useContainerWidth(); const currentLevel = useCurrentLevel(timeline); const { tooltipData, showTooltip, hideTooltip } = useTooltip(); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 5b1e704e..b677e6ac 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,24 +1,51 @@ export type Units = "meters" | "feet"; -import type { Station as BaseStation } from "@neaps/tide-database"; -export interface Station extends BaseStation { - defaultDatum: string; +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 type StationSummary = Pick< - Station, - | "id" - | "name" - | "latitude" - | "longitude" - | "region" - | "country" - | "continent" - | "timezone" - | "type" -> & { - 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; diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx index 49003b27..83bf5230 100644 --- a/packages/react/test/a11y.test.tsx +++ b/packages/react/test/a11y.test.tsx @@ -5,7 +5,7 @@ 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 { TideGraph } from "../src/components/TideGraph/index.js"; +import { TideGraphStatic } from "../src/components/TideGraph/index.js"; import { createTestWrapper } from "./helpers.js"; async function checkA11y(container: HTMLElement) { @@ -83,9 +83,12 @@ describe("accessibility", () => { { time: new Date("2025-12-17T18:00:00Z"), level: 1.4 }, ]; - const { container } = render(, { - wrapper: createTestWrapper(), - }); + const { container } = render( + , + { + wrapper: createTestWrapper(), + }, + ); await checkA11y(container); }); diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 31bdd1ca..846b36f6 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { render, waitFor } from "@testing-library/react"; -import { TideGraph } from "../../src/components/TideGraph/index.js"; +import { TideGraphStatic } from "../../src/components/TideGraph/index.js"; import { createTestWrapper } from "../helpers.js"; const timeline = [ @@ -19,10 +19,10 @@ const extremes = [ { time: new Date("2025-12-17T12:00:00Z"), level: 0.3, high: false, low: true, label: "Low" }, ]; -describe("TideGraph", () => { - test("renders a canvas element in data-driven mode", async () => { +describe("TideGraphStatic", () => { + test("renders an svg element", async () => { const { container } = render( - , + , { wrapper: createTestWrapper() }, ); @@ -32,9 +32,10 @@ describe("TideGraph", () => { }); test("renders with empty extremes", async () => { - const { container } = render(, { - wrapper: createTestWrapper(), - }); + const { container } = render( + , + { wrapper: createTestWrapper() }, + ); await waitFor(() => { expect(container.querySelector("svg")).not.toBeNull(); @@ -43,7 +44,7 @@ describe("TideGraph", () => { test("applies className", () => { const { container } = render( - , + , { wrapper: createTestWrapper() }, ); From 965195b0a603f07033ce508bd879e89a10dede82 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 07:36:08 -0500 Subject: [PATCH 23/48] Cleanup --- .../src/components/TideGraph/TideGraph.tsx | 264 ++++++++++++++++- .../components/TideGraph/TideGraphScroll.tsx | 266 ------------------ .../components/TideGraph/TideGraphStatic.tsx | 56 ---- .../react/src/components/TideGraph/index.ts | 2 - packages/react/src/types.ts | 2 +- packages/react/test/a11y.test.tsx | 13 +- .../react/test/components/TideGraph.test.tsx | 45 +-- 7 files changed, 288 insertions(+), 360 deletions(-) delete mode 100644 packages/react/src/components/TideGraph/TideGraphScroll.tsx delete mode 100644 packages/react/src/components/TideGraph/TideGraphStatic.tsx diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx index 6ae79b1d..dbee83d0 100644 --- a/packages/react/src/components/TideGraph/TideGraph.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -1,6 +1,13 @@ -import { useNeapsConfig } from "../../provider.js"; -import { TideGraphScroll } from "./TideGraphScroll.js"; -import { PX_PER_DAY_DEFAULT } from "./constants.js"; +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"; export interface TideGraphProps { id: string; @@ -8,15 +15,250 @@ export interface TideGraphProps { className?: string; } -export function TideGraph(props: TideGraphProps) { - const config = useNeapsConfig(); +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) + useLayoutEffect(() => { + 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/TideGraphScroll.tsx b/packages/react/src/components/TideGraph/TideGraphScroll.tsx deleted file mode 100644 index 097e9ecb..00000000 --- a/packages/react/src/components/TideGraph/TideGraphScroll.tsx +++ /dev/null @@ -1,266 +0,0 @@ -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 } from "./constants.js"; -import type { TimelineEntry } from "../../types.js"; - -export function TideGraphScroll({ - id, - pxPerDay, - className, -}: { - id: string; - pxPerDay: number; - className?: string; -}) { - 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) - useLayoutEffect(() => { - 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/TideGraphStatic.tsx b/packages/react/src/components/TideGraph/TideGraphStatic.tsx deleted file mode 100644 index 634e4147..00000000 --- a/packages/react/src/components/TideGraph/TideGraphStatic.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useCallback } from "react"; -import { useTooltip } from "@visx/tooltip"; - -import { useContainerWidth } from "../../hooks/use-container-width.js"; -import { useCurrentLevel } from "../../hooks/use-current-level.js"; -import { TideGraphChart } from "./TideGraphChart.js"; -import type { TimelineEntry, Extreme, Units } from "../../types.js"; - -export interface TideGraphStaticProps { - timeline: TimelineEntry[]; - extremes?: Extreme[]; - timezone?: string; - units?: Units; - locale?: string; - className?: string; -} - -export function TideGraphStatic({ - timeline, - extremes = [], - timezone = "UTC", - units = "feet", - locale = "en", - className, -}: TideGraphStaticProps) { - const { ref: containerRef, width: containerWidth } = useContainerWidth(); - const currentLevel = useCurrentLevel(timeline); - const { tooltipData, showTooltip, hideTooltip } = useTooltip(); - - const activeEntry = tooltipData ?? currentLevel; - - const handleSelect = useCallback( - (entry: TimelineEntry | null) => { - if (entry) showTooltip({ tooltipData: entry }); - else hideTooltip(); - }, - [showTooltip, hideTooltip], - ); - - return ( -
    - {containerWidth > 0 && ( - - )} -
    - ); -} diff --git a/packages/react/src/components/TideGraph/index.ts b/packages/react/src/components/TideGraph/index.ts index 9f8587a4..b8852bac 100644 --- a/packages/react/src/components/TideGraph/index.ts +++ b/packages/react/src/components/TideGraph/index.ts @@ -1,6 +1,4 @@ export * from "./TideGraph.js"; export * from "./TideGraphChart.js"; -export * from "./TideGraphScroll.js"; -export * from "./TideGraphStatic.js"; export * from "./NightBands.js"; export * from "./YAxisOverlay.js"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b677e6ac..8dc17ccd 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -5,7 +5,7 @@ export interface StationSummary { name: string; latitude: number; longitude: number; - region: string; + region?: string; country: string; continent: string; timezone: string; diff --git a/packages/react/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx index 83bf5230..80fcf278 100644 --- a/packages/react/test/a11y.test.tsx +++ b/packages/react/test/a11y.test.tsx @@ -5,7 +5,7 @@ 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 { TideGraphStatic } from "../src/components/TideGraph/index.js"; +import { TideGraphChart } from "../src/components/TideGraph/index.js"; import { createTestWrapper } from "./helpers.js"; async function checkA11y(container: HTMLElement) { @@ -75,7 +75,7 @@ describe("accessibility", () => { await checkA11y(container); }); - test("TideGraph with data has no violations", async () => { + 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 }, @@ -84,7 +84,14 @@ describe("accessibility", () => { ]; const { container } = render( - , + {}} + />, { wrapper: createTestWrapper(), }, diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 846b36f6..b48349d3 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; -import { render, waitFor } from "@testing-library/react"; -import { TideGraphStatic } from "../../src/components/TideGraph/index.js"; +import { render } from "@testing-library/react"; +import { TideGraphChart } from "../../src/components/TideGraph/index.js"; import { createTestWrapper } from "../helpers.js"; const timeline = [ @@ -19,35 +19,38 @@ const extremes = [ { time: new Date("2025-12-17T12:00:00Z"), level: 0.3, high: false, low: true, label: "Low" }, ]; -describe("TideGraphStatic", () => { - test("renders an svg element", async () => { - const { container } = render( - , - { wrapper: createTestWrapper() }, - ); - - await waitFor(() => { - expect(container.querySelector("svg")).not.toBeNull(); - }); - }); +const noop = () => {}; - test("renders with empty extremes", async () => { +describe("TideGraphChart", () => { + test("renders an svg element", () => { const { container } = render( - , + , { wrapper: createTestWrapper() }, ); - await waitFor(() => { - expect(container.querySelector("svg")).not.toBeNull(); - }); + expect(container.querySelector("svg")).not.toBeNull(); }); - test("applies className", () => { + test("renders with empty extremes", () => { const { container } = render( - , + , { wrapper: createTestWrapper() }, ); - expect(container.querySelector(".my-graph")).not.toBeNull(); + expect(container.querySelector("svg")).not.toBeNull(); }); }); From 450a0f5e1edac39c531195cfdedd0b0e81fdf704 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 10:55:27 -0500 Subject: [PATCH 24/48] Show TideConditions in map popup --- packages/react/package.json | 2 + .../src/components/StationsMap.stories.tsx | 5 +- packages/react/src/components/StationsMap.tsx | 92 ++++------- .../src/components/TideConditions.stories.tsx | 36 +++++ .../react/src/components/TideConditions.tsx | 152 ++++++++++++++---- .../react/src/components/TideCycleGraph.tsx | 7 +- .../src/components/TideStation.stories.tsx | 7 - packages/react/src/constants.ts | 2 + packages/react/src/hooks/use-theme-colors.ts | 18 ++- 9 files changed, 217 insertions(+), 104 deletions(-) create mode 100644 packages/react/src/components/TideConditions.stories.tsx create mode 100644 packages/react/src/constants.ts diff --git a/packages/react/package.json b/packages/react/package.json index 7d75d919..a6cff8e8 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -64,6 +64,7 @@ "@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" }, @@ -73,6 +74,7 @@ "@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", diff --git a/packages/react/src/components/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx index e2df5e13..01990a42 100644 --- a/packages/react/src/components/StationsMap.stories.tsx +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -5,9 +5,12 @@ import { StationsMap } from "./StationsMap.js"; const meta: Meta = { title: "Components/StationsMap", component: StationsMap, + parameters: { + layout: "fullscreen", + }, decorators: [ (Story) => ( -
    +
    ), diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index cd743c80..b2c9d40f 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -21,12 +21,11 @@ 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 { useExtremes } from "../hooks/use-extremes.js"; import { useNeapsConfig } from "../provider.js"; import { useDarkMode } from "../hooks/use-dark-mode.js"; import { useThemeColors } from "../hooks/use-theme-colors.js"; -import { formatLevel, formatTime } from "../utils/format.js"; -import type { StationSummary, Extreme } from "../types.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"; @@ -61,51 +60,19 @@ export interface StationsMapProps extends Omit, Manag function stationsToGeoJSON(stations: StationSummary[]): GeoJSON.FeatureCollection { return { type: "FeatureCollection", - features: stations.map((s) => ({ - type: "Feature" as const, - geometry: { type: "Point" as const, coordinates: [s.longitude, s.latitude] }, - properties: { id: s.id, name: s.name, region: s.region, country: s.country, type: s.type }, + features: stations.map(({ longitude, latitude, ...properties }) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [longitude, latitude], + }, + properties, })), }; } -function getNextExtreme(extremes: Extreme[]): Extreme | null { - const now = new Date(); - return extremes.find((e) => e.time > now) ?? null; -} - function StationPreviewCard({ stationId }: { stationId: string }) { - const config = useNeapsConfig(); - const now = new Date(); - const end = new Date(now.getTime() + 24 * 60 * 60 * 1000); - const { data, isLoading } = useExtremes({ - id: stationId, - start: now.toISOString(), - end: end.toISOString(), - }); - - if (isLoading) { - return Loading...; - } - - if (!data) return null; - - const next = getNextExtreme(data.extremes); - return ( -
    - {next && ( -
    - Next {next.high ? "High" : "Low"}: - - {formatLevel(next.level, data.units ?? config.units)}{" "} - - at {formatTime(next.time, data.station?.timezone ?? "UTC", config.locale)} - - -
    - )} -
    - ); + return ; } export const StationsMap = forwardRef(function StationsMap( @@ -117,7 +84,7 @@ export const StationsMap = forwardRef(function Station focusStation, showGeolocation = true, clustering = true, - clusterMaxZoom: clusterMaxZoomProp = 14, + clusterMaxZoom: clusterMaxZoomProp = 7, clusterRadius: clusterRadiusProp = 50, popupContent = "preview", children, @@ -198,26 +165,20 @@ export const StationsMap = forwardRef(function Station // Station point click if (props?.id) { const coords = (feature.geometry as GeoJSON.Point).coordinates; - const station: StationSummary = { - id: props.id, - name: props.name, + const station = { + ...props, latitude: coords[1], longitude: coords[0], - region: props.region ?? "", - country: props.country ?? "", - continent: "", - timezone: "", - type: props.type ?? "reference", - }; + } as StationSummary; - if (popupContent === "preview" ? (viewState.zoom ?? 0) >= 10 : popupContent !== false) { + if (popupContent !== false) { setSelectedStation(station); } onStationSelect?.(station); } }, - [onStationSelect, viewState.zoom, popupContent], + [onStationSelect, popupContent], ); const handleLocateMe = useCallback(() => { @@ -372,18 +333,29 @@ export const StationsMap = forwardRef(function Station setSelectedStation(null)} closeOnClick={false} - className="neaps-popup" + closeButton={false} > -
    +
    {typeof popupContent === "function" ? ( popupContent(selectedStation) ) : ( <> -
    - {selectedStation.name} +
    +
    + {selectedStation.name} +
    +
    {popupContent === "simple" ? (
    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 index 98af7325..56b5b2b9 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -1,26 +1,54 @@ +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"; -export interface TideConditionsProps { +interface TideConditionsDataProps { timeline: TimelineEntry[]; extremes: Extreme[]; units: Units; timezone: string; - className?: string; } -function getNextExtreme(extremes: Extreme[]): Extreme | null { - const now = new Date(); - return extremes.find((e) => e.time > now) ?? null; +interface TideConditionsFetchProps { + id: string; } -function isTideRising(extremes: Extreme[]): boolean | null { - const next = getNextExtreme(extremes); - if (!next) return null; - return next.high; +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"; @@ -52,16 +80,20 @@ export function WaterLevelAtTime({ const stateIcon = state ? STATE_ICON[state] : null; return (
    -
    {label}
    - - {formatLevel(level, units)} + {label} {stateIcon && ( - + {stateIcon.icon} )} +
    + + {formatLevel(level, units)} {time.toLocaleString(locale, { @@ -72,17 +104,17 @@ export function WaterLevelAtTime({ ); } -export function TideConditions({ +function TideConditionsStatic({ timeline, extremes, units, - className, timezone, -}: TideConditionsProps) { + showDate, + className, +}: TideConditionsDataProps & { showDate: boolean; className?: string }) { const { locale } = useNeapsConfig(); const currentLevel = useCurrentLevel(timeline); - const nextExtreme = getNextExtreme(extremes); - const rising = isTideRising(extremes); + const { current: nearExtreme, next: nextExtreme } = getNearestExtremes(extremes); if (!currentLevel) { return ( @@ -96,21 +128,33 @@ export function TideConditions({
    -
    -

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

    -
    -
    + {showDate && ( +
    +

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

    +
    + )} +
    @@ -131,3 +175,51 @@ export function TideConditions({
    ); } + +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 index 768ac88f..83d114c2 100644 --- a/packages/react/src/components/TideCycleGraph.tsx +++ b/packages/react/src/components/TideCycleGraph.tsx @@ -6,8 +6,7 @@ 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"; - -const HALF_WINDOW_MS = 6.417 * 60 * 60 * 1000; +import { HALF_TIDE_CYCLE_MS } from "../constants.js"; const MARGIN: Margin = { top: 0, right: 0, bottom: 0, left: 0 }; export interface TideCycleGraphProps { @@ -133,8 +132,8 @@ export function TideCycleGraph({ timeline, extremes, className }: TideCycleGraph return () => observer.disconnect(); }, []); - const windowStart = now - HALF_WINDOW_MS; - const windowEnd = now + HALF_WINDOW_MS; + const windowStart = now - HALF_TIDE_CYCLE_MS; + const windowEnd = now + HALF_TIDE_CYCLE_MS; const windowTimeline = useMemo( () => diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx index 992e0e88..9108c167 100644 --- a/packages/react/src/components/TideStation.stories.tsx +++ b/packages/react/src/components/TideStation.stories.tsx @@ -23,13 +23,6 @@ export const Default: Story = { }, }; -export const WithTable: Story = { - args: { - id: "noaa/8443970", - showTable: true, - }, -}; - export const WidgetSize: Story = { args: { id: "noaa/8443970", 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/use-theme-colors.ts b/packages/react/src/hooks/use-theme-colors.ts index ffdb3690..f4b5b2fd 100644 --- a/packages/react/src/hooks/use-theme-colors.ts +++ b/packages/react/src/hooks/use-theme-colors.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { formatHex } from "culori"; import { useDarkMode } from "./use-dark-mode.js"; export interface ThemeColors { @@ -27,13 +28,26 @@ const FALLBACKS: ThemeColors = { border: "#e2e8f0", }; +/** + * 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; - return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; + const raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + if (!raw) return fallback; + return formatHex(raw) ?? fallback; } -/** Add alpha transparency to a hex color string, returning rgba(). */ +/** 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 From 1beb6075917bfd2155e0ffeffa30feb8bf3236d6 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 11:11:36 -0500 Subject: [PATCH 25/48] Remove min-h on map --- packages/react/src/components/StationsMap.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index b2c9d40f..f471ad90 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -21,7 +21,6 @@ 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 { useNeapsConfig } from "../provider.js"; import { useDarkMode } from "../hooks/use-dark-mode.js"; import { useThemeColors } from "../hooks/use-theme-colors.js"; import { TideConditions } from "./TideConditions.js"; @@ -198,7 +197,7 @@ export const StationsMap = forwardRef(function Station }, []); return ( -
    +
    Date: Tue, 3 Mar 2026 10:57:41 -0500 Subject: [PATCH 26/48] Add mini station map story --- .../src/components/StationsMap.stories.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/react/src/components/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx index 01990a42..28059d88 100644 --- a/packages/react/src/components/StationsMap.stories.tsx +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -57,6 +57,31 @@ export const DarkMode: Story = { ], }; +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", From 3dde08d03568be62fe086fb6c45214b5ee4a5642 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 12:39:05 -0500 Subject: [PATCH 27/48] Fix lint error --- packages/react/src/components/StationsMap.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx index 28059d88..432e0dc6 100644 --- a/packages/react/src/components/StationsMap.stories.tsx +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -80,7 +80,7 @@ export const Mini: Story = {
    ), ], -} +}; export const Loading: Story = { args: { From d21923774bb8d0719bb9cadcf7cae34af81f1851 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 14:31:04 -0500 Subject: [PATCH 28/48] Fix bbox handling in Stations Map --- packages/react/src/client.ts | 1 + packages/react/src/components/StationsMap.tsx | 29 ++++++++++++++----- packages/react/src/hooks/use-stations.ts | 2 +- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts index ecf70593..fc40838d 100644 --- a/packages/react/src/client.ts +++ b/packages/react/src/client.ts @@ -28,6 +28,7 @@ export interface StationsSearchParams { longitude?: number; maxResults?: number; maxDistance?: number; + bbox?: string; // "minLon,minLat,maxLon,maxLat" } async function fetchJSON(url: string): Promise { diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index f471ad90..48180459 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -14,6 +14,7 @@ import { 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"; @@ -93,7 +94,7 @@ export const StationsMap = forwardRef(function Station ref, ) { const [viewState, setViewState] = useState(mapProps.initialViewState ?? {}); - const [bbox, setBbox] = useState<[min: [number, number], max: [number, number]] | null>(null); + const [bbox, setBbox] = useState<[number, number, number, number] | null>(null); const debouncedSetBbox = useDebouncedCallback(setBbox, 200); const [selectedStation, setSelectedStation] = useState(null); @@ -105,7 +106,8 @@ export const StationsMap = forwardRef(function Station data: stations = [], isLoading, isError, - } = useStations(bbox ? { bbox } : {}, { + } = useStations(bbox ? { bbox: bbox.join(",") } : {}, { + enabled: bbox !== null, placeholderData: keepPreviousData, }); @@ -128,11 +130,13 @@ export const StationsMap = forwardRef(function Station return stationsToGeoJSON([focusStationData]); }, [focusStationData]); - const handleMove = useCallback( - (e: ViewStateChangeEvent) => { - setViewState(e.viewState); - const mapBounds = e.target.getBounds(); - debouncedSetBbox(mapBounds.toArray() as [[number, number], [number, number]]); + 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(), @@ -140,7 +144,15 @@ export const StationsMap = forwardRef(function Station west: mapBounds.getWest(), }); }, - [onBoundsChange], + [onBoundsChange, debouncedSetBbox], + ); + + const handleMove = useCallback( + (e: ViewStateChangeEvent) => { + setViewState(e.viewState); + updateBbox(e); + }, + [updateBbox], ); const handleMapClick = useCallback( @@ -202,6 +214,7 @@ export const StationsMap = forwardRef(function Station ref={ref} {...mapProps} {...viewState} + onLoad={updateBbox} onMove={handleMove} onClick={handleMapClick} interactiveLayerIds={["clusters", "unclustered-point"]} diff --git a/packages/react/src/hooks/use-stations.ts b/packages/react/src/hooks/use-stations.ts index 60630c5a..790c0763 100644 --- a/packages/react/src/hooks/use-stations.ts +++ b/packages/react/src/hooks/use-stations.ts @@ -3,7 +3,7 @@ import { useNeapsConfig } from "../provider.js"; import { fetchStations, type StationsSearchParams } from "../client.js"; import type { StationSummary } from "../types.js"; -type StationsQueryOptions = Pick, "placeholderData">; +type StationsQueryOptions = Pick, "placeholderData" | "enabled">; export function useStations(params: StationsSearchParams = {}, options: StationsQueryOptions = {}) { const { baseUrl } = useNeapsConfig(); From 57a5c2a23070b157c8c9703281fdc52eb02d3ade Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 14:46:54 -0500 Subject: [PATCH 29/48] Add aliases to run again local src --- .github/workflows/ci.yml | 3 +++ packages/react/vitest.config.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b4ee245..6002ede6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,9 @@ jobs: - 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 diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index 5ea463a7..e30a9ead 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -1,7 +1,15 @@ import { defineProject } from "vitest/config"; import { playwright } from "@vitest/browser-playwright"; +import { resolve } from "node:path"; 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"), + }, + }, test: { browser: { enabled: true, From b955a3277ccecc90bc2cd01201c95969119aac41 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 14:48:40 -0500 Subject: [PATCH 30/48] Build before publshing to pk.pr.new --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6002ede6..348fc716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,10 +78,12 @@ jobs: - 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/* From 78614c7b42cb05abc1091d5df6abab5617e7f2fe Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 15:39:37 -0500 Subject: [PATCH 31/48] Center date labels on sunrise/sunset midpoint --- .../components/TideGraph/TideGraphChart.tsx | 31 +++++++++------ packages/react/src/utils/sun.ts | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/react/src/components/TideGraph/TideGraphChart.tsx b/packages/react/src/components/TideGraph/TideGraphChart.tsx index c6fbdafe..ce4d3641 100644 --- a/packages/react/src/components/TideGraph/TideGraphChart.tsx +++ b/packages/react/src/components/TideGraph/TideGraphChart.tsx @@ -9,6 +9,7 @@ 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"; @@ -88,6 +89,23 @@ export function TideGraphChart({ // 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 ( @@ -239,24 +257,15 @@ export function TideGraphChart({ {/* Top axis — date ticks */} {(() => { - const [start, end] = xScale.domain(); - 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); - } const fmt = new Intl.DateTimeFormat(locale, { timeZone: timezone, month: "short" }); - const months = dates.map((dt) => fmt.format(dt)); + 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]; diff --git a/packages/react/src/utils/sun.ts b/packages/react/src/utils/sun.ts index f224a122..10547c2d 100644 --- a/packages/react/src/utils/sun.ts +++ b/packages/react/src/utils/sun.ts @@ -7,6 +7,45 @@ export interface NightInterval { 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. From 9b4894b504e13082546ccfa74ee5677eefbafcef Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 15:39:55 -0500 Subject: [PATCH 32/48] Share vitest resolve config with storybook --- packages/react/vite.config.ts | 14 ++++++++++++++ packages/react/vitest.config.ts | 9 --------- 2 files changed, 14 insertions(+), 9 deletions(-) create mode 100644 packages/react/vite.config.ts diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts new file mode 100644 index 00000000..10080569 --- /dev/null +++ b/packages/react/vite.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path"; +import { defineConfig } from "vite"; + +const packages = resolve(__dirname, ".."); + +export default defineConfig({ + resolve: { + alias: { + neaps: resolve(packages, "neaps/src/index.ts"), + "@neaps/api": resolve(packages, "api/src/index.ts"), + "@neaps/tide-predictor": resolve(packages, "tide-predictor/src/index.ts"), + }, + }, +}); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index e30a9ead..d5098e05 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -1,15 +1,7 @@ import { defineProject } from "vitest/config"; import { playwright } from "@vitest/browser-playwright"; -import { resolve } from "node:path"; 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"), - }, - }, test: { browser: { enabled: true, @@ -18,7 +10,6 @@ export default defineProject({ { browser: "chromium", headless: true, - screenshotFailures: true, }, ], }, From 5f389542658995c4daa6f3ab97e9393995ee0cfc Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 15:54:53 -0500 Subject: [PATCH 33/48] Improve local alias handling --- aliases.ts | 30 ++++++++++++++++++++++++++++++ packages/api/vitest.config.ts | 6 ++---- packages/cli/vitest.config.ts | 7 ++----- packages/neaps/vitest.config.ts | 5 ++--- packages/react/vite.config.ts | 10 ++-------- packages/react/vitest.config.ts | 4 ++++ 6 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 aliases.ts 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/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/vite.config.ts b/packages/react/vite.config.ts index 10080569..833c5027 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -1,14 +1,8 @@ -import { resolve } from "node:path"; import { defineConfig } from "vite"; - -const packages = resolve(__dirname, ".."); +import { aliases } from "../../aliases.js"; export default defineConfig({ resolve: { - alias: { - neaps: resolve(packages, "neaps/src/index.ts"), - "@neaps/api": resolve(packages, "api/src/index.ts"), - "@neaps/tide-predictor": resolve(packages, "tide-predictor/src/index.ts"), - }, + alias: aliases("@neaps/react"), }, }); diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index d5098e05..f3ea73c8 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -1,7 +1,11 @@ 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, From c7f0eb55941d790c8a3f51f71a6726579e914a7d Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 22:06:30 -0500 Subject: [PATCH 34/48] Fix distance for nearby stations --- packages/react/src/components/NearbyStations.tsx | 2 +- packages/react/src/utils/format.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/NearbyStations.tsx b/packages/react/src/components/NearbyStations.tsx index efb9627c..065b69fb 100644 --- a/packages/react/src/components/NearbyStations.tsx +++ b/packages/react/src/components/NearbyStations.tsx @@ -136,7 +136,7 @@ function NearbyFromPosition({
    {station.distance !== undefined && ( - {formatDistance(station.distance, config.units)} + {formatDistance(station.distance * 1000, config.units)} )}
    diff --git a/packages/react/src/utils/format.ts b/packages/react/src/utils/format.ts index 98c55bcb..71d6a17d 100644 --- a/packages/react/src/utils/format.ts +++ b/packages/react/src/utils/format.ts @@ -30,9 +30,12 @@ export function formatDate(time: Date, timezone: string, locale?: string): strin export function formatDistance(meters: number, units: Units): string { if (units === "feet") { const miles = meters / 1609.344; - return miles < 0.1 ? `${Math.round(meters * 3.2808399)} ft` : `${miles.toFixed(1)} mi`; + if (miles < 0.1) return `${Math.round(meters * 3.2808399)} ft`; + return miles >= 10 ? `${Math.round(miles)} mi` : `${miles.toFixed(1)} mi`; } - return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`; + 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. */ From e42c50ce9182f55ee30fa809b64b63224db3205a Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Thu, 5 Mar 2026 22:07:35 -0500 Subject: [PATCH 35/48] Exclude current station from nearby stations --- .../react/src/components/NearbyStations.tsx | 60 ++++++++++--------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/react/src/components/NearbyStations.tsx b/packages/react/src/components/NearbyStations.tsx index 065b69fb..86f648c5 100644 --- a/packages/react/src/components/NearbyStations.tsx +++ b/packages/react/src/components/NearbyStations.tsx @@ -69,6 +69,7 @@ function NearbyFromStation({ void; onHover?: (station: StationSummary) => void; @@ -103,7 +106,7 @@ function NearbyFromPosition({ } = useNearbyStations({ latitude, longitude, - maxResults, + maxResults: excludeId ? maxResults + 1 : maxResults, }); if (isLoading) @@ -116,33 +119,36 @@ function NearbyFromPosition({ return (
      - {stations.map((station) => ( -
    • - -
    • - ))} +
    +
    + {station.distance !== undefined && ( + + {formatDistance(station.distance * 1000, config.units)} + + )} +
    + + + ))} ); } From 84e9f383809177a841d41d05d93c6187969e73d8 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 07:47:40 -0500 Subject: [PATCH 36/48] Fix lint errors --- packages/react/src/components/NearbyStations.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/NearbyStations.tsx b/packages/react/src/components/NearbyStations.tsx index 86f648c5..395514d0 100644 --- a/packages/react/src/components/NearbyStations.tsx +++ b/packages/react/src/components/NearbyStations.tsx @@ -134,7 +134,9 @@ function NearbyFromPosition({ onBlur={() => onHoverEnd?.(station)} >
    - {station.name} + + {station.name} + {[station.region, station.country].filter(Boolean).join(", ")} From 9596180ec6c9129dccd999f80dea8cdb5115f805 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 10:20:33 -0500 Subject: [PATCH 37/48] Fix base url handling --- packages/react/src/client.ts | 4 +++- packages/react/test/client.test.ts | 31 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts index fc40838d..9d2bb399 100644 --- a/packages/react/src/client.ts +++ b/packages/react/src/client.ts @@ -41,7 +41,9 @@ async function fetchJSON(url: string): Promise { } function buildURL(base: string, path: string, params: object = {}): string { - const url = new URL(path, base); + 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)); diff --git a/packages/react/test/client.test.ts b/packages/react/test/client.test.ts index 7e6244fb..8a6f01e8 100644 --- a/packages/react/test/client.test.ts +++ b/packages/react/test/client.test.ts @@ -9,6 +9,7 @@ import { } 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(); @@ -25,6 +26,36 @@ function mockFetch(data: unknown, status = 200) { ); } +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" }); From 196b24755abc05dd3fa46dc480c2e3af94896478 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 10:52:42 -0500 Subject: [PATCH 38/48] Add support for SSR hydration --- .../src/components/TideGraph/TideGraph.tsx | 5 +- packages/react/src/components/TideStation.tsx | 9 +-- packages/react/src/hooks/use-extremes.ts | 3 +- .../react/src/hooks/use-nearby-stations.ts | 3 +- packages/react/src/hooks/use-station.ts | 3 +- packages/react/src/hooks/use-stations.ts | 3 +- packages/react/src/hooks/use-tide-chunks.ts | 5 +- packages/react/src/hooks/use-timeline.ts | 3 +- packages/react/src/index.ts | 6 ++ packages/react/src/prefetch.ts | 70 +++++++++++++++++++ packages/react/src/provider.tsx | 44 +++++++----- packages/react/src/query-keys.ts | 7 ++ packages/react/src/utils/defaults.ts | 21 ++++++ 13 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 packages/react/src/prefetch.ts create mode 100644 packages/react/src/query-keys.ts create mode 100644 packages/react/src/utils/defaults.ts diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx index dbee83d0..d3ff8f31 100644 --- a/packages/react/src/components/TideGraph/TideGraph.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -9,6 +9,9 @@ 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; @@ -95,7 +98,7 @@ export function TideGraph({ id, pxPerDay = PX_PER_DAY_DEFAULT, className }: Tide }, [timeline.length, dataStart, totalMs, innerW]); // Preserve scroll position when chunks prepend (leftward) - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { const container = scrollRef.current; if (!container || prevDataStartRef.current === null || prevScrollWidthRef.current === null) return; diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index f781e9b0..9cb73c19 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -11,6 +11,7 @@ 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; @@ -19,14 +20,6 @@ export interface TideStationProps { className?: string; } -function getDefaultRange(): { start: string; end: string } { - const start = new Date(); - start.setHours(0, 0, 0, 0); - const end = new Date(start); - end.setDate(end.getDate() + 7); - return { start: start.toISOString(), end: end.toISOString() }; -} - export function TideStation({ id, showGraph = true, diff --git a/packages/react/src/hooks/use-extremes.ts b/packages/react/src/hooks/use-extremes.ts index 301438d9..93e323ad 100644 --- a/packages/react/src/hooks/use-extremes.ts +++ b/packages/react/src/hooks/use-extremes.ts @@ -7,6 +7,7 @@ import { type StationPredictionParams, type PredictionParams, } from "../client.js"; +import { queryKeys } from "../query-keys.js"; export type UseExtremesParams = | ({ id: string } & PredictionParams) @@ -18,7 +19,7 @@ export function useExtremes(params: UseExtremesParams) { const mergedDatum = params.datum ?? datum; return useQuery({ - queryKey: ["neaps", "extremes", { ...params, units: mergedUnits, datum: mergedDatum }], + queryKey: queryKeys.extremes({ ...params, units: mergedUnits, datum: mergedDatum }), queryFn: () => { if (params.id) { return fetchStationExtremes(baseUrl, { diff --git a/packages/react/src/hooks/use-nearby-stations.ts b/packages/react/src/hooks/use-nearby-stations.ts index 756a506c..cec5850b 100644 --- a/packages/react/src/hooks/use-nearby-stations.ts +++ b/packages/react/src/hooks/use-nearby-stations.ts @@ -1,6 +1,7 @@ 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; @@ -13,7 +14,7 @@ export function useNearbyStations(params: UseNearbyStationsParams | undefined) { const { baseUrl } = useNeapsConfig(); return useQuery({ - queryKey: ["neaps", "nearby-stations", params], + 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 index ee3eb140..0dda8be1 100644 --- a/packages/react/src/hooks/use-station.ts +++ b/packages/react/src/hooks/use-station.ts @@ -1,12 +1,13 @@ 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: ["neaps", "station", id], + 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 index 790c0763..a01e6169 100644 --- a/packages/react/src/hooks/use-stations.ts +++ b/packages/react/src/hooks/use-stations.ts @@ -2,6 +2,7 @@ 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">; @@ -9,7 +10,7 @@ export function useStations(params: StationsSearchParams = {}, options: Stations const { baseUrl } = useNeapsConfig(); return useQuery({ - queryKey: ["neaps", "stations", params], + queryKey: queryKeys.stations(params), queryFn: () => fetchStations(baseUrl, params), ...options, }); diff --git a/packages/react/src/hooks/use-tide-chunks.ts b/packages/react/src/hooks/use-tide-chunks.ts index 0718a898..14c75709 100644 --- a/packages/react/src/hooks/use-tide-chunks.ts +++ b/packages/react/src/hooks/use-tide-chunks.ts @@ -3,6 +3,7 @@ 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; @@ -55,7 +56,7 @@ export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn const timelineQueries = useQueries({ queries: chunks.map((chunk) => ({ - queryKey: ["neaps", "timeline", { id, start: chunk.start, end: chunk.end, units, datum }], + 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, @@ -64,7 +65,7 @@ export function useTideChunks({ id }: UseTideChunksParams): UseTideChunksReturn const extremesQueries = useQueries({ queries: chunks.map((chunk) => ({ - queryKey: ["neaps", "extremes", { id, start: chunk.start, end: chunk.end, units, datum }], + 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, diff --git a/packages/react/src/hooks/use-timeline.ts b/packages/react/src/hooks/use-timeline.ts index 9249a28e..980cac22 100644 --- a/packages/react/src/hooks/use-timeline.ts +++ b/packages/react/src/hooks/use-timeline.ts @@ -7,6 +7,7 @@ import { type StationPredictionParams, type PredictionParams, } from "../client.js"; +import { queryKeys } from "../query-keys.js"; export type UseTimelineParams = | ({ id: string } & PredictionParams) @@ -18,7 +19,7 @@ export function useTimeline(params: UseTimelineParams) { const mergedDatum = params.datum ?? datum; return useQuery({ - queryKey: ["neaps", "timeline", { ...params, units: mergedUnits, datum: mergedDatum }], + queryKey: queryKeys.timeline({ ...params, units: mergedUnits, datum: mergedDatum }), queryFn: () => { if (params.id) { return fetchStationTimeline(baseUrl, { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 60a83dd6..0ac49595 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -3,6 +3,12 @@ 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..b97b708a --- /dev/null +++ b/packages/react/src/prefetch.ts @@ -0,0 +1,70 @@ +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 } from "./utils/defaults.js"; + +export interface PrefetchTideStationOptions { + units: Units; + 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 ``. + */ +export async function prefetchTideStation( + queryClient: QueryClient, + baseUrl: string, + id: string, + { units, datum }: PrefetchTideStationOptions, +): Promise { + const range = getDefaultRange(); + const params = { id, start: range.start, end: range.end, units, 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 index a7cc25c5..64b01605 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,12 +1,10 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Units } from "./types.js"; - -const IMPERIAL_LOCALES = ["en-US", "en-LR", "my-MM"]; +import { getDefaultUnits } from "./utils/defaults.js"; const defaultLocale = typeof navigator !== "undefined" ? navigator.language : "en-US"; -const defaultUnits: Units = IMPERIAL_LOCALES.includes(defaultLocale) ? "feet" : "meters"; export interface NeapsConfig { baseUrl: string; @@ -49,19 +47,24 @@ function saveSettings(settings: PersistedSettings): void { } } +/** 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 { - if (!defaultQueryClient) { - defaultQueryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - refetchOnWindowFocus: false, - }, - }, - }); - } + // 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; } @@ -77,14 +80,23 @@ export interface NeapsProviderProps { export function NeapsProvider({ baseUrl, - units: initialUnits = defaultUnits, + units: initialUnits = getDefaultUnits(), datum: initialDatum, timezone: initialTimezone, locale: initialLocale = defaultLocale, queryClient, children, }: NeapsProviderProps) { - const [overrides, setOverrides] = useState(loadSettings); + // 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( () => ({ 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/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() }; +} From 7250abb389c76b77912b3b98bda95ed23e1b71c4 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 10:57:38 -0500 Subject: [PATCH 39/48] Improve test coverage --- .../react/test/components/NightBands.test.tsx | 48 ++++ .../components/StationDisclaimers.test.tsx | 39 +++ .../test/components/TideConditions.test.tsx | 231 ++++++++++++++++++ .../test/components/TideCycleGraph.test.tsx | 52 ++++ .../test/components/TideGraphFull.test.tsx | 175 +++++++++++++ .../test/components/TideSettings.test.tsx | 95 +++++++ .../components/TideStationHeader.test.tsx | 52 ++++ .../test/components/TideTableFetcher.test.tsx | 110 +++++++++ .../test/components/YAxisOverlay.test.tsx | 71 ++++++ packages/react/test/defaults.test.ts | 59 +++++ packages/react/test/provider.test.tsx | 114 ++++++++- packages/react/test/sun.test.ts | 106 ++++++++ packages/react/test/use-current-level.test.ts | 75 ++++++ 13 files changed, 1223 insertions(+), 4 deletions(-) create mode 100644 packages/react/test/components/NightBands.test.tsx create mode 100644 packages/react/test/components/StationDisclaimers.test.tsx create mode 100644 packages/react/test/components/TideConditions.test.tsx create mode 100644 packages/react/test/components/TideCycleGraph.test.tsx create mode 100644 packages/react/test/components/TideGraphFull.test.tsx create mode 100644 packages/react/test/components/TideSettings.test.tsx create mode 100644 packages/react/test/components/TideStationHeader.test.tsx create mode 100644 packages/react/test/components/TideTableFetcher.test.tsx create mode 100644 packages/react/test/components/YAxisOverlay.test.tsx create mode 100644 packages/react/test/defaults.test.ts create mode 100644 packages/react/test/sun.test.ts create mode 100644 packages/react/test/use-current-level.test.ts 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/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/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/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/TideTableFetcher.test.tsx b/packages/react/test/components/TideTableFetcher.test.tsx new file mode 100644 index 00000000..78891219 --- /dev/null +++ b/packages/react/test/components/TideTableFetcher.test.tsx @@ -0,0 +1,110 @@ +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(), + }); + const view = within(container); + + // Provider defaults to feet (en-US locale) + 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..fcab8aa7 --- /dev/null +++ b/packages/react/test/components/YAxisOverlay.test.tsx @@ -0,0 +1,71 @@ +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: [-2, 5], + range: [200, 0], + nice: true, + }); + + const { container } = render( + , + ); + + // Wide range uses Math.round, so tick labels should be integers without decimals + const ticks = container.querySelectorAll("text"); + for (const tick of ticks) { + const label = tick.textContent!.trim(); + if (label) { + expect(label).toMatch(/^-?\d+ 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/provider.test.tsx b/packages/react/test/provider.test.tsx index c49a5ca8..d217d358 100644 --- a/packages/react/test/provider.test.tsx +++ b/packages/react/test/provider.test.tsx @@ -1,8 +1,12 @@ -import { describe, test, expect } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { NeapsProvider, useNeapsConfig } from "../src/provider.js"; +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 ( @@ -35,9 +39,111 @@ describe("NeapsProvider", () => { expect(result.current.datum).toBeUndefined(); }); - test("throws when used outside provider", () => { + 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/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); + }); +}); From 1b3af236851c1d405840c803cfef8d68baa4e493 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 12:48:37 -0500 Subject: [PATCH 40/48] Add custom map text/bg overrides --- packages/react/src/components/StationsMap.tsx | 23 ++++++---------- packages/react/src/hooks/use-theme-colors.ts | 27 +++++++++++++------ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index 48180459..9dba5d3a 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -22,7 +22,6 @@ 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 { useDarkMode } from "../hooks/use-dark-mode.js"; import { useThemeColors } from "../hooks/use-theme-colors.js"; import { TideConditions } from "./TideConditions.js"; import type { StationSummary } from "../types.js"; @@ -31,8 +30,6 @@ import type { StationSummary } from "../types.js"; type ManagedMapProps = "onMove" | "onClick" | "interactiveLayerIds" | "style" | "cursor"; export interface StationsMapProps extends Omit, ManagedMapProps> { - /** Optional dark mode style URL. Switches automatically based on .dark class or prefers-color-scheme. */ - darkMapStyle?: string; onStationSelect?: (station: StationSummary) => void; onBoundsChange?: (bounds: { north: number; south: number; east: number; west: number }) => void; /** Whether to show the geolocation button. Defaults to true. */ @@ -77,8 +74,6 @@ function StationPreviewCard({ stationId }: { stationId: string }) { export const StationsMap = forwardRef(function StationsMap( { - mapStyle, - darkMapStyle, onStationSelect, onBoundsChange, focusStation, @@ -98,9 +93,7 @@ export const StationsMap = forwardRef(function Station const debouncedSetBbox = useDebouncedCallback(setBbox, 200); const [selectedStation, setSelectedStation] = useState(null); - const isDarkMode = useDarkMode(); const colors = useThemeColors(); - const effectiveMapStyle = isDarkMode && darkMapStyle ? darkMapStyle : mapStyle; const { data: stations = [], @@ -218,7 +211,6 @@ export const StationsMap = forwardRef(function Station onMove={handleMove} onClick={handleMapClick} interactiveLayerIds={["clusters", "unclustered-point"]} - mapStyle={effectiveMapStyle} style={{ width: "100%", height: "100%" }} cursor="pointer" attributionControl={false} @@ -291,17 +283,18 @@ export const StationsMap = forwardRef(function Station id="station-labels" type="symbol" filter={["!", ["has", "point_count"]]} - minzoom={8} + minzoom={7} layout={{ "text-field": ["get", "name"], - "text-size": 11, + "text-size": 13, "text-offset": [0, 1.5], "text-anchor": "top", "text-max-width": 10, + "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], }} paint={{ - "text-color": colors.text, - "text-halo-color": colors.bg, + "text-color": colors.mapText, + "text-halo-color": colors.mapBg, "text-halo-width": 1.5, }} /> @@ -325,15 +318,15 @@ export const StationsMap = forwardRef(function Station type="symbol" layout={{ "text-field": ["get", "name"], - "text-size": 12, + "text-size": 14, "text-offset": [0, 1.8], "text-anchor": "top", "text-max-width": 10, "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], }} paint={{ - "text-color": colors.text, - "text-halo-color": colors.bg, + "text-color": colors.mapText, + "text-halo-color": colors.mapBg, "text-halo-width": 2, }} /> diff --git a/packages/react/src/hooks/use-theme-colors.ts b/packages/react/src/hooks/use-theme-colors.ts index f4b5b2fd..b078577a 100644 --- a/packages/react/src/hooks/use-theme-colors.ts +++ b/packages/react/src/hooks/use-theme-colors.ts @@ -13,6 +13,8 @@ export interface ThemeColors { text: string; textMuted: string; border: string; + mapText: string; + mapBg: string; } const FALLBACKS: ThemeColors = { @@ -26,6 +28,8 @@ const FALLBACKS: ThemeColors = { text: "#0f172a", textMuted: "#64748b", border: "#e2e8f0", + mapText: "#0f172a", + mapBg: "#ffffff", }; /** @@ -63,23 +67,30 @@ export function withAlpha(color: string, alpha: number): string { /** * Reads resolved `--neaps-*` CSS custom property values from the DOM. - * Re-computes when dark mode toggles so Chart.js (canvas) gets correct colors. + * 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( - () => ({ + 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: readCSSVar("--neaps-bg", FALLBACKS.bg), + bg, bgSubtle: readCSSVar("--neaps-bg-subtle", FALLBACKS.bgSubtle), - text: readCSSVar("--neaps-text", FALLBACKS.text), + text, textMuted: readCSSVar("--neaps-text-muted", FALLBACKS.textMuted), border: readCSSVar("--neaps-border", FALLBACKS.border), - }), - [isDark], - ); + mapText: readCSSVar("--neaps-map-text", text), + mapBg: readCSSVar("--neaps-map-bg", bg), + }; + }, [isDark]); } From 2b4cce80c0087e3c9107a7f6fb0dfc789f5dfea6 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 13:01:59 -0500 Subject: [PATCH 41/48] Lint and test fixes --- packages/react/src/components/TideGraph/TideGraph.tsx | 3 +-- packages/react/src/provider.tsx | 10 +++++++++- packages/react/test/components/YAxisOverlay.test.tsx | 11 +++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx index d3ff8f31..7b897d07 100644 --- a/packages/react/src/components/TideGraph/TideGraph.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -9,8 +9,7 @@ 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; +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; export interface TideGraphProps { id: string; diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 64b01605..065c43c5 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -1,4 +1,12 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Units } from "./types.js"; diff --git a/packages/react/test/components/YAxisOverlay.test.tsx b/packages/react/test/components/YAxisOverlay.test.tsx index fcab8aa7..dd7261cd 100644 --- a/packages/react/test/components/YAxisOverlay.test.tsx +++ b/packages/react/test/components/YAxisOverlay.test.tsx @@ -50,7 +50,7 @@ describe("YAxisOverlay", () => { test("formats wide range with rounded numbers", () => { const wideYScale = scaleLinear({ - domain: [-2, 5], + domain: [-1, 4], range: [200, 0], nice: true, }); @@ -60,12 +60,7 @@ describe("YAxisOverlay", () => { ); // Wide range uses Math.round, so tick labels should be integers without decimals - const ticks = container.querySelectorAll("text"); - for (const tick of ticks) { - const label = tick.textContent!.trim(); - if (label) { - expect(label).toMatch(/^-?\d+ m$/); - } - } + const text = container.textContent!; + expect(text).toContain("-1 m"); }); }); From db4794f49be5ab3bf0748ca9f042716c864eb0f3 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 14:06:55 -0500 Subject: [PATCH 42/48] Add locale to prefetching --- packages/react/src/prefetch.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/react/src/prefetch.ts b/packages/react/src/prefetch.ts index b97b708a..6ad9bc1d 100644 --- a/packages/react/src/prefetch.ts +++ b/packages/react/src/prefetch.ts @@ -8,26 +8,30 @@ import { fetchStationExtremes, fetchStations, } from "./client.js"; -import { getDefaultRange } from "./utils/defaults.js"; +import { getDefaultRange, getDefaultUnits } from "./utils/defaults.js"; export interface PrefetchTideStationOptions { - units: Units; + 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 ``. + * 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, datum }: PrefetchTideStationOptions, + { units, locale, datum }: PrefetchTideStationOptions = {}, ): Promise { + const resolvedUnits = units ?? getDefaultUnits(locale); const range = getDefaultRange(); - const params = { id, start: range.start, end: range.end, units, datum }; + const params = { id, start: range.start, end: range.end, units: resolvedUnits, datum }; await Promise.all([ queryClient.prefetchQuery({ From 8750b1738b73bbd4986a2adf89456b669a1d578b Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Fri, 6 Mar 2026 14:29:24 -0500 Subject: [PATCH 43/48] Exclude stories from coverage --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) 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"], }, }, }); From bc368fe592757332193acdc23fcd7db80855f29c Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 06:28:42 -0500 Subject: [PATCH 44/48] Use `color-scheme` css property to control dark mode --- packages/react/.storybook/main.ts | 1 + packages/react/.storybook/manager.ts | 6 ++-- packages/react/.storybook/preview.tsx | 9 ++++++ packages/react/.storybook/storybook.css | 8 +++++ packages/react/.storybook/theme.ts | 8 +++-- packages/react/README.md | 19 ++++++++---- packages/react/package.json | 1 + packages/react/src/styles.css | 40 ++++++++++--------------- 8 files changed, 56 insertions(+), 36 deletions(-) diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts index aba81e3d..7245a85c 100644 --- a/packages/react/.storybook/main.ts +++ b/packages/react/.storybook/main.ts @@ -6,6 +6,7 @@ const API_PORT = 6007; const config: StorybookConfig = { stories: ["../src/**/*.stories.@(ts|tsx)"], + addons: ["@storybook/addon-themes"], framework: { name: "@storybook/react-vite", options: {}, diff --git a/packages/react/.storybook/manager.ts b/packages/react/.storybook/manager.ts index ca35c801..2326dd7f 100644 --- a/packages/react/.storybook/manager.ts +++ b/packages/react/.storybook/manager.ts @@ -1,4 +1,6 @@ import { addons } from "storybook/internal/manager-api"; -import theme from "./theme.js"; +import { light, dark } from "./theme.js"; -addons.setConfig({ theme }); +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 index 7de32334..104ea9fa 100644 --- a/packages/react/.storybook/preview.tsx +++ b/packages/react/.storybook/preview.tsx @@ -1,4 +1,5 @@ import type { Preview } from "@storybook/react"; +import { withThemeByDataAttribute } from "@storybook/addon-themes"; import { NeapsProvider } from "../src/provider.js"; import "./storybook.css"; @@ -11,6 +12,14 @@ const preview: Preview = { ), + withThemeByDataAttribute({ + themes: { + light: "light", + dark: "dark", + }, + defaultTheme: "light", + attributeName: "data-theme", + }), ], parameters: { controls: { diff --git a/packages/react/.storybook/storybook.css b/packages/react/.storybook/storybook.css index 2ff2fc11..ea057e00 100644 --- a/packages/react/.storybook/storybook.css +++ b/packages/react/.storybook/storybook.css @@ -1,2 +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 index 9fe078fa..2b865657 100644 --- a/packages/react/.storybook/theme.ts +++ b/packages/react/.storybook/theme.ts @@ -1,7 +1,9 @@ import { create } from "storybook/internal/theming"; -export default create({ - base: "normal", +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 index e118cb5a..841ec0f5 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -180,14 +180,21 @@ Override CSS custom properties to match your brand: ### Dark Mode -Dark mode activates when a parent element has the `dark` class or the user's system preference is `prefers-color-scheme: dark`. Override dark mode colors: +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 -.dark { - --neaps-primary: #60a5fa; - --neaps-bg: #0f172a; - --neaps-text: #f1f5f9; - /* ... */ +.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); } ``` diff --git a/packages/react/package.json b/packages/react/package.json index a6cff8e8..5a67537a 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -69,6 +69,7 @@ "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", diff --git a/packages/react/src/styles.css b/packages/react/src/styles.css index b8baf8db..45121f3a 100644 --- a/packages/react/src/styles.css +++ b/packages/react/src/styles.css @@ -6,31 +6,21 @@ */ :root { - --neaps-primary: var(--color-sky-600, #0284c7); - --neaps-secondary: var(--color-violet-600, #7c3aed); - --neaps-high: var(--color-teal-600, #0d9488); - --neaps-low: var(--color-amber-600, #d97706); - --neaps-danger: var(--color-red-500, #ef4444); - --neaps-bg: var(--color-white, #ffffff); - --neaps-bg-subtle: var(--color-slate-50, #f8fafc); - --neaps-text: var(--color-slate-900, #0f172a); - --neaps-text-muted: var(--color-slate-500, #64748b); - --neaps-border: var(--color-slate-200, #e2e8f0); - --neaps-night: var(--color-slate-100, #eef2f6); -} - -.dark { - --neaps-primary: var(--color-sky-400, #38bdf8); - --neaps-secondary: var(--color-violet-400, #a78bfa); - --neaps-high: var(--color-teal-400, #2dd4bf); - --neaps-low: var(--color-amber-400, #fbbf24); - --neaps-danger: var(--color-red-400, #f87171); - --neaps-bg: var(--color-slate-900, #0f172a); - --neaps-bg-subtle: var(--color-slate-800, #1e293b); - --neaps-text: var(--color-slate-100, #f1f5f9); - --neaps-text-muted: var(--color-slate-400, #94a3b8); - --neaps-border: var(--color-slate-700, #334155); - --neaps-night: var(--color-slate-950, #020617); + 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 */ From 2663cc3e6b54e3e5ad2a8826777d4dd06db981d4 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 06:50:05 -0500 Subject: [PATCH 45/48] Try setting default locale --- packages/react/src/provider.tsx | 4 ++-- packages/react/test/helpers.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index 065c43c5..663d3a68 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -88,10 +88,10 @@ export interface NeapsProviderProps { export function NeapsProvider({ baseUrl, - units: initialUnits = getDefaultUnits(), + locale: initialLocale = defaultLocale, + units: initialUnits = getDefaultUnits(initialLocale), datum: initialDatum, timezone: initialTimezone, - locale: initialLocale = defaultLocale, queryClient, children, }: NeapsProviderProps) { diff --git a/packages/react/test/helpers.tsx b/packages/react/test/helpers.tsx index b832cd75..5503c826 100644 --- a/packages/react/test/helpers.tsx +++ b/packages/react/test/helpers.tsx @@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query"; import { NeapsProvider } from "../src/provider.js"; import type { ReactNode } from "react"; -export function createTestWrapper({ baseUrl }: { baseUrl?: string } = {}) { +export function createTestWrapper({ baseUrl, locale }: { baseUrl?: string; locale?: string } = {}) { const url = baseUrl ?? inject("apiBaseUrl"); const queryClient = new QueryClient({ defaultOptions: { @@ -15,7 +15,7 @@ export function createTestWrapper({ baseUrl }: { baseUrl?: string } = {}) { return function TestWrapper({ children }: { children: ReactNode }) { return ( - + {children} ); From 30e6bb8e9d1cba0bcfb82f7c8543840b4ded951a Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 07:15:00 -0500 Subject: [PATCH 46/48] Remove dark stories after adding theme toggle --- .../src/components/NearbyStations.stories.tsx | 14 -------------- .../react/src/components/StationSearch.stories.tsx | 13 ------------- .../react/src/components/StationsMap.stories.tsx | 14 -------------- .../src/components/TideGraph/TideGraph.stories.tsx | 13 ------------- .../react/src/components/TideStation.stories.tsx | 14 -------------- .../react/src/components/TideTable.stories.tsx | 14 -------------- 6 files changed, 82 deletions(-) diff --git a/packages/react/src/components/NearbyStations.stories.tsx b/packages/react/src/components/NearbyStations.stories.tsx index 52e33565..0504bbac 100644 --- a/packages/react/src/components/NearbyStations.stories.tsx +++ b/packages/react/src/components/NearbyStations.stories.tsx @@ -40,20 +40,6 @@ export const LimitedResults: Story = { }, }; -export const DarkMode: Story = { - args: { - stationId: "noaa/8443970", - onStationSelect: (station) => console.log("Selected:", station), - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const Loading: Story = { args: { stationId: "noaa/8443970", diff --git a/packages/react/src/components/StationSearch.stories.tsx b/packages/react/src/components/StationSearch.stories.tsx index fdb8d9a3..758ea5c0 100644 --- a/packages/react/src/components/StationSearch.stories.tsx +++ b/packages/react/src/components/StationSearch.stories.tsx @@ -30,19 +30,6 @@ export const CustomPlaceholder: Story = { }, }; -export const DarkMode: Story = { - args: { - onSelect: (station) => console.log("Selected:", station), - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const Loading: Story = { args: { onSelect: (station) => console.log("Selected:", station), diff --git a/packages/react/src/components/StationsMap.stories.tsx b/packages/react/src/components/StationsMap.stories.tsx index 432e0dc6..50aa300e 100644 --- a/packages/react/src/components/StationsMap.stories.tsx +++ b/packages/react/src/components/StationsMap.stories.tsx @@ -43,20 +43,6 @@ export const HighZoom: Story = { }, }; -export const DarkMode: Story = { - args: { - mapStyle: "https://demotiles.maplibre.org/style.json", - onStationSelect: (station) => console.log("Selected:", station), - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const Mini: Story = { args: { mapStyle: "https://demotiles.maplibre.org/style.json", diff --git a/packages/react/src/components/TideGraph/TideGraph.stories.tsx b/packages/react/src/components/TideGraph/TideGraph.stories.tsx index 4f8f4b2c..ff2bcc7b 100644 --- a/packages/react/src/components/TideGraph/TideGraph.stories.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.stories.tsx @@ -60,19 +60,6 @@ export const DesktopWidth: Story = { ], }; -export const DarkMode: Story = { - args: { - id: "noaa/8443970", - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const Loading: Story = { args: { id: "noaa/8443970", diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx index 9108c167..a7d313d3 100644 --- a/packages/react/src/components/TideStation.stories.tsx +++ b/packages/react/src/components/TideStation.stories.tsx @@ -65,20 +65,6 @@ export const DesktopSideBySide: Story = { ], }; -export const DarkMode: Story = { - args: { - id: "noaa/8443970", - showTable: true, - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const FrenchLocale: Story = { args: { id: "noaa/8443970", diff --git a/packages/react/src/components/TideTable.stories.tsx b/packages/react/src/components/TideTable.stories.tsx index 9e2b41bc..66b21d0c 100644 --- a/packages/react/src/components/TideTable.stories.tsx +++ b/packages/react/src/components/TideTable.stories.tsx @@ -52,20 +52,6 @@ export const NarrowWidth: Story = { ], }; -export const DarkMode: Story = { - args: { - id: "noaa/8443970", - days: 3, - }, - decorators: [ - (Story) => ( -
    - -
    - ), - ], -}; - export const Loading: Story = { args: { id: "noaa/8443970", From ea8d1e88d4a82ee23e303b56d66bba51b4c2b44d Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 07:33:56 -0500 Subject: [PATCH 47/48] Make units explicit in flaky test --- .../react/test/components/TideTableFetcher.test.tsx | 3 +-- packages/react/test/helpers.tsx | 10 ++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/react/test/components/TideTableFetcher.test.tsx b/packages/react/test/components/TideTableFetcher.test.tsx index 78891219..82ac2dc4 100644 --- a/packages/react/test/components/TideTableFetcher.test.tsx +++ b/packages/react/test/components/TideTableFetcher.test.tsx @@ -100,11 +100,10 @@ describe("TideTable data grouping and state", () => { ]; const { container } = render(, { - wrapper: createTestWrapper(), + wrapper: createTestWrapper({ units: "feet" }), }); const view = within(container); - // Provider defaults to feet (en-US locale) expect(view.getByText("1.5 ft")).toBeDefined(); }); }); diff --git a/packages/react/test/helpers.tsx b/packages/react/test/helpers.tsx index 5503c826..1bd9bb0e 100644 --- a/packages/react/test/helpers.tsx +++ b/packages/react/test/helpers.tsx @@ -1,10 +1,12 @@ import { inject } from "vitest"; import { QueryClient } from "@tanstack/react-query"; -import { NeapsProvider } from "../src/provider.js"; +import { NeapsProvider, NeapsProviderProps } from "../src/provider.js"; import type { ReactNode } from "react"; -export function createTestWrapper({ baseUrl, locale }: { baseUrl?: string; locale?: string } = {}) { - const url = baseUrl ?? inject("apiBaseUrl"); +export function createTestWrapper({ + baseUrl = inject("apiBaseUrl"), + ...props +}: Partial = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -15,7 +17,7 @@ export function createTestWrapper({ baseUrl, locale }: { baseUrl?: string; local return function TestWrapper({ children }: { children: ReactNode }) { return ( - + {children} ); From c12079feb205ae8a77b1d64aecd8dde1ce3ae38f Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sat, 7 Mar 2026 15:20:40 -0500 Subject: [PATCH 48/48] Remove settings from localstorage after each test --- packages/react/test/setup.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react/test/setup.ts b/packages/react/test/setup.ts index c10eddee..e494e0e2 100644 --- a/packages/react/test/setup.ts +++ b/packages/react/test/setup.ts @@ -3,4 +3,5 @@ import { cleanup } from "@testing-library/react"; afterEach(() => { cleanup(); + localStorage.removeItem("neaps-settings"); });
    Date @@ -107,11 +107,10 @@ function TideTableView({ {extreme.label} From bf3c3f33bf6c4f0102c687c9f19c27f173df223f Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 12:06:46 -0500 Subject: [PATCH 16/48] Play with mobile experience on TideGraph --- .../react/src/components/TideConditions.tsx | 4 +- packages/react/src/components/TideGraph.tsx | 279 +++++++++++++----- .../react/src/components/TideSettings.tsx | 4 +- .../src/components/TideStation.stories.tsx | 14 - packages/react/src/components/TideStation.tsx | 2 +- packages/react/src/components/TideTable.tsx | 14 +- packages/react/src/hooks/use-current-level.ts | 2 +- 7 files changed, 218 insertions(+), 101 deletions(-) diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx index 1ece0a1e..137d5a42 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -123,7 +123,9 @@ export function TideConditions({ locale={locale} state={nextExtreme.high ? "high" : "low"} /> - ) :
    } + ) : ( +
    + )} diff --git a/packages/react/src/components/TideGraph.tsx b/packages/react/src/components/TideGraph.tsx index dbd0a113..cfb2f5da 100644 --- a/packages/react/src/components/TideGraph.tsx +++ b/packages/react/src/components/TideGraph.tsx @@ -8,7 +8,7 @@ import { localPoint } from "@visx/event"; import { bisector } from "d3-array"; import { useTideChunks } from "../hooks/use-tide-chunks.js"; -import { useCurrentLevel } from "../hooks/use-current-level.js"; +import { useCurrentLevel, interpolateLevel } from "../hooks/use-current-level.js"; import { useNeapsConfig } from "../provider.js"; import { formatLevel, formatTime } from "../utils/format.js"; import { useTideScales, type Margin } from "../utils/scales.js"; @@ -20,7 +20,6 @@ export interface TideGraphDataProps { extremes?: Extreme[]; timezone?: string; units?: Units; - datum?: string; } export interface TideGraphFetchProps { @@ -66,25 +65,25 @@ function TideGraphChart({ extremes, timezone, units, - datum, locale, svgWidth, yDomainOverride, latitude, longitude, className, + onSelect, }: { timeline: TimelineEntry[]; extremes: Extreme[]; timezone: string; units: Units; - datum?: string; locale: string; svgWidth: number; yDomainOverride?: [number, number]; latitude?: number; longitude?: number; className?: string; + onSelect?: (entry: TimelineEntry | null, sticky?: boolean) => void; }) { const gradientId = useId(); const currentLevel = useCurrentLevel(timeline); @@ -100,20 +99,38 @@ function TideGraphChart({ const { showTooltip, hideTooltip, tooltipData } = useTooltip(); - const handlePointerMove = useCallback( - (event: React.PointerEvent) => { - if (event.pointerType === "touch") return; + const findNearestEntry = useCallback( + (event: React.PointerEvent): TimelineEntry | null => { const point = localPoint(event); - if (!point) return; + 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; - const d = d1 && x0 - new Date(d0.time).getTime() > new Date(d1.time).getTime() - x0 ? d1 : d0; - showTooltip({ tooltipData: d }); + if (!d0) return null; + return d1 && x0 - new Date(d0.time).getTime() > new Date(d1.time).getTime() - x0 ? d1 : d0; }, - [xScale, timeline, showTooltip], + [xScale, timeline], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType === "touch") return; + const d = findNearestEntry(event); + if (!d) return; + if (onSelect) onSelect(d); + else showTooltip({ tooltipData: d }); + }, + [findNearestEntry, showTooltip, onSelect], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (event.pointerType !== "touch") return; + const d = findNearestEntry(event); + if (d) onSelect?.(d, true); + }, + [findNearestEntry, onSelect], ); const nightIntervals = useMemo(() => { @@ -225,7 +242,7 @@ function TideGraphChart({ /> {/* Active point: shows hovered point, or current level when idle */} - {(() => { + {!onSelect && (() => { const active = tooltipData ?? currentLevel; if (!active) return null; const x = xScale(new Date(active.time).getTime()); @@ -272,11 +289,11 @@ function TideGraphChart({ })()} {/* Extreme points + labels */} - {extremes.map((e, i) => { + {extremes.map((e) => { const cx = xScale(new Date(e.time).getTime()); const cy = yScale(e.level); return ( - + (onSelect ? onSelect(null) : hideTooltip())} /> @@ -400,16 +418,12 @@ function TideGraphChart({ function YAxisOverlay({ yScale, - innerH, narrowRange, unitSuffix, - datum, }: { yScale: ReturnType["yScale"]; - innerH: number; narrowRange: boolean; unitSuffix: string; - datum?: string; }) { return (
    (null); const prevScrollWidthRef = useRef(null); const hasScrolledToNow = useRef(false); + const overlayRef = useRef(null); const { timeline, @@ -476,12 +491,12 @@ function TideGraphScroll({ station, timezone, units, - datum, } = 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, innerH } = useTideScales({ @@ -501,18 +516,88 @@ function TideGraphScroll({ const unitSuffix = units === "feet" ? "ft" : "m"; + // Unified annotation: hover > pinned > current level, clamped toward "now" + const currentLevel = useCurrentLevel(timeline); + const [hoverData, setHoverData] = useState(null); + const [pinnedData, setPinnedData] = useState(null); + const handleSelect = useCallback((entry: TimelineEntry | null, sticky?: boolean) => { + if (sticky) setPinnedData(entry); + else setHoverData(entry); + }, []); + + // active: the conceptual anchor point (hover, pinned, or current moment) + const active = hoverData ?? pinnedData ?? currentLevel; + // displayedEntry: the interpolated level at the *clamped* overlay position, + // which may differ from active when the overlay is pinned to a viewport edge. + const [displayedEntry, setDisplayedEntry] = useState(null); + + const activeX = useMemo(() => { + if (!active) return null; + const ms = new Date(active.time).getTime(); + return ((ms - dataStart) / totalMs) * innerW + MARGIN.left; + }, [active, dataStart, totalMs, innerW]); + + useLayoutEffect(() => { + const container = scrollRef.current; + const overlay = overlayRef.current; + if (!container || !overlay || activeX === null) return; + + const nowMs = currentLevel ? new Date(currentLevel.time).getTime() : null; + const nowSvgX = nowMs !== null ? ((nowMs - dataStart) / totalMs) * innerW + MARGIN.left : null; + const pinnedSvgX = pinnedData + ? ((new Date(pinnedData.time).getTime() - dataStart) / totalMs) * innerW + MARGIN.left + : null; + + function update() { + const left = container!.scrollLeft; + const w = container!.clientWidth; + + // Clamp the overlay to the viewport + const vx = activeX! - left; + const clamped = Math.max(100, Math.min(w - 50, vx)); + overlay!.style.left = `${clamped}px`; + + // Reverse-map the clamped position to a chart time and interpolate + const svgX = clamped + left; + const ms = ((svgX - MARGIN.left) / innerW) * totalMs + dataStart; + const entry = interpolateLevel(timeline, ms); + setDisplayedEntry((prev) => { + if (prev?.time === entry?.time && prev?.level === entry?.level) return prev; + return entry; + }); + + // Today direction: show button when pinned or when now is off-screen + if (nowSvgX !== null) { + const dir: "left" | "right" | null = pinnedData + ? (new Date(pinnedData.time).getTime() > nowMs! ? "left" : "right") + : nowSvgX < left ? "left" + : nowSvgX > left + w ? "right" + : null; + setTodayDirection((prev) => (prev === dir ? prev : dir)); + } + + // Clear pinned point when it scrolls out of view + if (pinnedSvgX !== null) { + const pvx = pinnedSvgX - left; + if (pvx < 0 || pvx > w) setPinnedData(null); + } + } + + update(); + container.addEventListener("scroll", update, { passive: true }); + return () => container.removeEventListener("scroll", update); + }, [activeX, innerW, totalMs, dataStart, timeline, currentLevel, pinnedData]); + // Scroll to "now" on initial data load useEffect(() => { if (hasScrolledToNow.current || !timeline.length || !scrollRef.current) return; const container = scrollRef.current; - const nowMs = Date.now(); - const nowFraction = (nowMs - dataStart) / totalMs; - const nowPx = nowFraction * (svgWidth - MARGIN.left - MARGIN.right) + MARGIN.left; + 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, svgWidth]); + }, [timeline.length, dataStart, totalMs, innerW]); // Preserve scroll position when chunks prepend (leftward) useLayoutEffect(() => { @@ -553,35 +638,16 @@ function TideGraphScroll({ return () => observer.disconnect(); }, [loadPrevious, loadNext, pxPerDay]); - // Track whether "now" is visible and which direction it is const [todayDirection, setTodayDirection] = useState<"left" | "right" | null>(null); - const getNowPx = useCallback(() => { - const nowMs = Date.now(); - return ((nowMs - dataStart) / totalMs) * (svgWidth - MARGIN.left - MARGIN.right) + MARGIN.left; - }, [dataStart, totalMs, svgWidth]); - - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - function onScroll() { - const nowPx = getNowPx(); - const left = container!.scrollLeft; - const right = left + container!.clientWidth; - if (nowPx < left) setTodayDirection("left"); - else if (nowPx > right) setTodayDirection("right"); - else setTodayDirection(null); - } - container.addEventListener("scroll", onScroll, { passive: true }); - return () => container.removeEventListener("scroll", onScroll); - }, [getNowPx]); - // Scroll to now handler const scrollToNow = useCallback(() => { + setPinnedData(null); const container = scrollRef.current; if (!container) return; - container.scrollTo({ left: getNowPx() - container.clientWidth / 2, behavior: "smooth" }); - }, [getNowPx]); + 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 ( @@ -624,6 +690,7 @@ function TideGraphScroll({ yDomainOverride={yDomain} latitude={station?.latitude} longitude={station?.longitude} + onSelect={handleSelect} /> {/* Right sentinel */} @@ -649,58 +716,126 @@ function TideGraphScroll({ {/* Y-axis overlay (fixed left) */} - {/* Today button — fades in when today is out of view, positioned toward today */} + {/* Active level overlay (stays in viewport, clamps toward "now") */} + {active && ( +
    +
    +
    +
    + {displayedEntry ? formatTime(displayedEntry.time, timezone, locale) : ""} +
    +
    + {displayedEntry ? formatLevel(displayedEntry.level, units) : ""} +
    +
    +
    + )} + + {/* Today button — fades in when a pinned point is active */}
    ); } +// ─── Data-driven (non-scrollable) chart ───────────────────────────────────── + +function TideGraphStatic({ + timeline, + extremes = [], + timezone = "UTC", + units, + locale, + className, +}: TideGraphDataProps & { locale: string; className?: string }) { + const { ref: containerRef, width: containerWidth } = useContainerWidth(); + return ( +
    + {containerWidth > 0 && ( + + )} +
    + ); +} + // ─── Public component ─────────────────────────────────────────────────────── export function TideGraph(props: TideGraphProps) { const config = useNeapsConfig(); - const pxPerDay = props.pxPerDay ?? PX_PER_DAY_DEFAULT; - // Data-driven mode: timeline/extremes passed directly (non-scrollable) if (props.timeline) { - const { ref: containerRef, width: containerWidth } = useContainerWidth(); return ( -
    - {containerWidth > 0 && ( - - )} -
    + ); } - // Fetch mode: scrollable infinite chart return ( diff --git a/packages/react/src/components/TideSettings.tsx b/packages/react/src/components/TideSettings.tsx index f98d4c6f..262fdb21 100644 --- a/packages/react/src/components/TideSettings.tsx +++ b/packages/react/src/components/TideSettings.tsx @@ -61,9 +61,7 @@ function buildTimezoneOptions(stationTimezone: string): TimezoneOption[] { const browserTimezone = typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : undefined; - const options: TimezoneOption[] = [ - { value: undefined, label: `Station (${stationTimezone})` }, - ]; + const options: TimezoneOption[] = [{ value: undefined, label: `Station (${stationTimezone})` }]; if (browserTimezone && browserTimezone !== stationTimezone) { options.push({ value: browserTimezone, label: `Local (${browserTimezone})` }); diff --git a/packages/react/src/components/TideStation.stories.tsx b/packages/react/src/components/TideStation.stories.tsx index cbef3a14..992e0e88 100644 --- a/packages/react/src/components/TideStation.stories.tsx +++ b/packages/react/src/components/TideStation.stories.tsx @@ -100,20 +100,6 @@ export const FrenchLocale: Story = { ], }; -export const ImperialUnits: Story = { - args: { - id: "noaa/8443970", - showTable: true, - }, - decorators: [ - (Story) => ( - - - - ), - ], -}; - export const Loading: Story = { args: { id: "noaa/8443970", diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index fdc5c4cd..99de0f7e 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -84,7 +84,7 @@ export function TideStation({ {showGraph && } {showTable && ( - + )} diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx index e0d8c5de..6c395d69 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -9,7 +9,6 @@ export interface TideTableDataProps { extremes: Extreme[]; timezone?: string; units?: Units; - datum?: string; } export interface TideTableFetchProps { @@ -28,14 +27,12 @@ function TideTableView({ extremes, timezone, units, - datum, locale, className, }: { extremes: Extreme[]; timezone: string; units: Units; - datum?: string; locale: string; className?: string; }) { @@ -107,10 +104,11 @@ function TideTableView({
    {extreme.label} @@ -135,7 +133,6 @@ export function TideTable(props: TideTableProps) { extremes={props.extremes} timezone={props.timezone ?? "UTC"} units={props.units ?? config.units} - datum={props.datum} locale={config.locale} className={props.className} /> @@ -176,7 +173,6 @@ function TideTableFetcher({ extremes={data?.extremes ?? []} timezone={data?.station?.timezone ?? "UTC"} units={data?.units ?? config.units} - datum={data?.datum} locale={config.locale} className={className} /> diff --git a/packages/react/src/hooks/use-current-level.ts b/packages/react/src/hooks/use-current-level.ts index e0caf4a1..aeb8efba 100644 --- a/packages/react/src/hooks/use-current-level.ts +++ b/packages/react/src/hooks/use-current-level.ts @@ -36,7 +36,7 @@ export function useCurrentLevel(timeline: TimelineEntry[]): TimelineEntry | null const minute = 60_000; const id = setInterval(() => { const next = Math.floor(Date.now() / minute) * minute; - if (now !== next) setNow(next); + setNow((prev) => (prev !== next ? next : prev)); }, 5_000); return () => clearInterval(id); }, []); From c296e38d39fce791607efb5d2af3d8e4d445cc45 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 17:04:47 -0500 Subject: [PATCH 17/48] Allow previewing storybook on local network --- packages/react/.storybook/main.ts | 4 ++-- packages/react/.storybook/preview.tsx | 2 +- packages/react/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react/.storybook/main.ts b/packages/react/.storybook/main.ts index f7ea4060..aba81e3d 100644 --- a/packages/react/.storybook/main.ts +++ b/packages/react/.storybook/main.ts @@ -17,8 +17,8 @@ const config: StorybookConfig = { name: "neaps-api", async configureServer() { const app = createApp(); - app.listen(API_PORT, () => { - console.log(`Neaps API listening on http://localhost:${API_PORT}`); + app.listen(API_PORT, "0.0.0.0", () => { + console.log(`Neaps API listening on http://0.0.0.0:${API_PORT}`); }); }, }); diff --git a/packages/react/.storybook/preview.tsx b/packages/react/.storybook/preview.tsx index e8337c13..7de32334 100644 --- a/packages/react/.storybook/preview.tsx +++ b/packages/react/.storybook/preview.tsx @@ -2,7 +2,7 @@ import type { Preview } from "@storybook/react"; import { NeapsProvider } from "../src/provider.js"; import "./storybook.css"; -const API_URL = "http://localhost:6007"; +const API_URL = `${window.location.protocol}//${window.location.hostname}:6007`; const preview: Preview = { decorators: [ diff --git a/packages/react/package.json b/packages/react/package.json index e0b2cb95..7d75d919 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -35,7 +35,7 @@ "build": "tsdown", "watch": "tsdown --watch", "prepack": "npm run build", - "storybook": "storybook dev -p 6006", + "storybook": "storybook dev -p 6006 --host 0.0.0.0", "build-storybook": "storybook build" }, "peerDependencies": { From 423829f7cfe09fbea08948bda037e74826cbea77 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 21:01:18 -0500 Subject: [PATCH 18/48] Refactor/simplify TideChart --- packages/react/src/components/TideGraph.tsx | 843 ------------------ .../src/components/TideGraph/NightBands.tsx | 39 + .../{ => TideGraph}/TideGraph.stories.tsx | 2 +- .../src/components/TideGraph/TideGraph.tsx | 48 + .../components/TideGraph/TideGraphChart.tsx | 348 ++++++++ .../components/TideGraph/TideGraphScroll.tsx | 273 ++++++ .../components/TideGraph/TideGraphStatic.tsx | 48 + .../src/components/TideGraph/YAxisOverlay.tsx | 46 + .../src/components/TideGraph/constants.ts | 6 + .../react/src/components/TideGraph/index.ts | 6 + packages/react/src/components/TideStation.tsx | 2 +- .../react/src/hooks/use-container-width.ts | 20 + packages/react/src/index.ts | 2 +- packages/react/src/utils/scales.ts | 4 + packages/react/test/a11y.test.tsx | 2 +- .../react/test/components/TideGraph.test.tsx | 2 +- 16 files changed, 843 insertions(+), 848 deletions(-) delete mode 100644 packages/react/src/components/TideGraph.tsx create mode 100644 packages/react/src/components/TideGraph/NightBands.tsx rename packages/react/src/components/{ => TideGraph}/TideGraph.stories.tsx (96%) create mode 100644 packages/react/src/components/TideGraph/TideGraph.tsx create mode 100644 packages/react/src/components/TideGraph/TideGraphChart.tsx create mode 100644 packages/react/src/components/TideGraph/TideGraphScroll.tsx create mode 100644 packages/react/src/components/TideGraph/TideGraphStatic.tsx create mode 100644 packages/react/src/components/TideGraph/YAxisOverlay.tsx create mode 100644 packages/react/src/components/TideGraph/constants.ts create mode 100644 packages/react/src/components/TideGraph/index.ts create mode 100644 packages/react/src/hooks/use-container-width.ts diff --git a/packages/react/src/components/TideGraph.tsx b/packages/react/src/components/TideGraph.tsx deleted file mode 100644 index cfb2f5da..00000000 --- a/packages/react/src/components/TideGraph.tsx +++ /dev/null @@ -1,843 +0,0 @@ -import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { AreaClosed, LinePath } from "@visx/shape"; -import { AxisTop, AxisLeft } from "@visx/axis"; -import { Group } from "@visx/group"; -import { curveNatural } from "@visx/curve"; -import { useTooltip } from "@visx/tooltip"; -import { localPoint } from "@visx/event"; -import { bisector } from "d3-array"; - -import { useTideChunks } from "../hooks/use-tide-chunks.js"; -import { useCurrentLevel, interpolateLevel } from "../hooks/use-current-level.js"; -import { useNeapsConfig } from "../provider.js"; -import { formatLevel, formatTime } from "../utils/format.js"; -import { useTideScales, type Margin } from "../utils/scales.js"; -import { getNightIntervals } from "../utils/sun.js"; -import type { TimelineEntry, Extreme, Units } from "../types.js"; - -export interface TideGraphDataProps { - timeline: TimelineEntry[]; - extremes?: Extreme[]; - timezone?: string; - units?: Units; -} - -export interface TideGraphFetchProps { - id: string; - timeline?: undefined; -} - -export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { - pxPerDay?: number; - className?: string; -}; - -const PX_PER_DAY_DEFAULT = 200; -const HEIGHT = 300; -const MARGIN: Margin = { top: 65, right: 0, bottom: 40, left: 60 }; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -const timelineBisector = bisector((d) => new Date(d.time).getTime()).left; - -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 }; -} - -// ─── Static (data-driven) chart ───────────────────────────────────────────── - -function TideGraphChart({ - timeline, - extremes, - timezone, - units, - locale, - svgWidth, - yDomainOverride, - latitude, - longitude, - className, - onSelect, -}: { - timeline: TimelineEntry[]; - extremes: Extreme[]; - timezone: string; - units: Units; - locale: string; - svgWidth: number; - yDomainOverride?: [number, number]; - latitude?: number; - longitude?: number; - className?: string; - onSelect?: (entry: TimelineEntry | null, sticky?: boolean) => void; -}) { - const gradientId = useId(); - const currentLevel = useCurrentLevel(timeline); - - const { xScale, yScale, innerW, innerH } = useTideScales({ - timeline, - extremes, - width: svgWidth, - height: HEIGHT, - margin: MARGIN, - yDomainOverride, - }); - - const { showTooltip, hideTooltip, tooltipData } = useTooltip(); - - 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 - new Date(d0.time).getTime() > new Date(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) return; - if (onSelect) onSelect(d); - else showTooltip({ tooltipData: d }); - }, - [findNearestEntry, showTooltip, onSelect], - ); - - const handlePointerUp = useCallback( - (event: React.PointerEvent) => { - if (event.pointerType !== "touch") return; - const d = findNearestEntry(event); - if (d) onSelect?.(d, true); - }, - [findNearestEntry, onSelect], - ); - - const nightIntervals = useMemo(() => { - if (latitude == null || longitude == null || !timeline.length) return []; - const [start, end] = xScale.domain(); - return getNightIntervals(latitude, longitude, start.getTime(), end.getTime()); - }, [latitude, longitude, timeline.length, xScale]); - - 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]); - - if (innerW <= 0 || svgWidth <= 0) return null; - - return ( - - - - - - - - - - - - - - - - - - - - {/* Night bands */} - {nightIntervals.map((interval, i) => { - const x1 = xScale(interval.start); - const x2 = xScale(interval.end); - return ( - - ); - })} - - {/* Zero reference line */} - - - {/* Area fill: positive (above zero) */} - xScale(new Date(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(new Date(d.time).getTime())} - y={(d) => yScale(d.level)} - yScale={zeroBaseScale} - curve={curveNatural} - fill={`url(#${gradientId}-neg)`} - clipPath={`url(#${gradientId}-clip-neg)`} - /> - xScale(new Date(d.time).getTime())} - y={(d) => yScale(d.level)} - curve={curveNatural} - stroke="var(--neaps-primary)" - strokeWidth={2} - /> - - {/* Active point: shows hovered point, or current level when idle */} - {!onSelect && (() => { - const active = tooltipData ?? currentLevel; - if (!active) return null; - const x = xScale(new Date(active.time).getTime()); - return ( - - - - - - {formatTime(active.time, timezone, locale)} - - - {formatLevel(active.level, units)} - - - - ); - })()} - - {/* Extreme points + labels */} - {extremes.map((e) => { - const cx = xScale(new Date(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 [start, end] = xScale.domain(); - 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); - } - const fmt = new Intl.DateTimeFormat(locale, { timeZone: timezone, month: "short" }); - const months = dates.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 ? onSelect(null) : hideTooltip())} - /> - - - ); -} - -// ─── Y-axis overlay (stays fixed on the left while chart scrolls) ─────────── - -function YAxisOverlay({ - yScale, - narrowRange, - unitSuffix, -}: { - yScale: ReturnType["yScale"]; - 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" }, - }} - /> - - -
    - ); -} - -// ─── Scrollable chart (fetch mode) ────────────────────────────────────────── - -function TideGraphScroll({ - id, - pxPerDay, - locale, - className, -}: { - id: string; - pxPerDay: number; - locale: string; - className?: string; -}) { - const scrollRef = useRef(null); - const prevDataStartRef = useRef(null); - const prevScrollWidthRef = useRef(null); - const hasScrolledToNow = useRef(false); - const overlayRef = useRef(null); - - 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, innerH } = 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"; - - // Unified annotation: hover > pinned > current level, clamped toward "now" - const currentLevel = useCurrentLevel(timeline); - const [hoverData, setHoverData] = useState(null); - const [pinnedData, setPinnedData] = useState(null); - const handleSelect = useCallback((entry: TimelineEntry | null, sticky?: boolean) => { - if (sticky) setPinnedData(entry); - else setHoverData(entry); - }, []); - - // active: the conceptual anchor point (hover, pinned, or current moment) - const active = hoverData ?? pinnedData ?? currentLevel; - // displayedEntry: the interpolated level at the *clamped* overlay position, - // which may differ from active when the overlay is pinned to a viewport edge. - const [displayedEntry, setDisplayedEntry] = useState(null); - - const activeX = useMemo(() => { - if (!active) return null; - const ms = new Date(active.time).getTime(); - return ((ms - dataStart) / totalMs) * innerW + MARGIN.left; - }, [active, dataStart, totalMs, innerW]); - - useLayoutEffect(() => { - const container = scrollRef.current; - const overlay = overlayRef.current; - if (!container || !overlay || activeX === null) return; - - const nowMs = currentLevel ? new Date(currentLevel.time).getTime() : null; - const nowSvgX = nowMs !== null ? ((nowMs - dataStart) / totalMs) * innerW + MARGIN.left : null; - const pinnedSvgX = pinnedData - ? ((new Date(pinnedData.time).getTime() - dataStart) / totalMs) * innerW + MARGIN.left - : null; - - function update() { - const left = container!.scrollLeft; - const w = container!.clientWidth; - - // Clamp the overlay to the viewport - const vx = activeX! - left; - const clamped = Math.max(100, Math.min(w - 50, vx)); - overlay!.style.left = `${clamped}px`; - - // Reverse-map the clamped position to a chart time and interpolate - const svgX = clamped + left; - const ms = ((svgX - MARGIN.left) / innerW) * totalMs + dataStart; - const entry = interpolateLevel(timeline, ms); - setDisplayedEntry((prev) => { - if (prev?.time === entry?.time && prev?.level === entry?.level) return prev; - return entry; - }); - - // Today direction: show button when pinned or when now is off-screen - if (nowSvgX !== null) { - const dir: "left" | "right" | null = pinnedData - ? (new Date(pinnedData.time).getTime() > nowMs! ? "left" : "right") - : nowSvgX < left ? "left" - : nowSvgX > left + w ? "right" - : null; - setTodayDirection((prev) => (prev === dir ? prev : dir)); - } - - // Clear pinned point when it scrolls out of view - if (pinnedSvgX !== null) { - const pvx = pinnedSvgX - left; - if (pvx < 0 || pvx > w) setPinnedData(null); - } - } - - update(); - container.addEventListener("scroll", update, { passive: true }); - return () => container.removeEventListener("scroll", update); - }, [activeX, innerW, totalMs, dataStart, timeline, currentLevel, pinnedData]); - - // 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) - useLayoutEffect(() => { - 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]); - - const [todayDirection, setTodayDirection] = useState<"left" | "right" | null>(null); - - // Scroll to now handler - const scrollToNow = useCallback(() => { - setPinnedData(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) */} - - - {/* Active level overlay (stays in viewport, clamps toward "now") */} - {active && ( -
    -
    -
    -
    - {displayedEntry ? formatTime(displayedEntry.time, timezone, locale) : ""} -
    -
    - {displayedEntry ? formatLevel(displayedEntry.level, units) : ""} -
    -
    -
    - )} - - {/* Today button — fades in when a pinned point is active */} - -
    -
    - ); -} - -// ─── Data-driven (non-scrollable) chart ───────────────────────────────────── - -function TideGraphStatic({ - timeline, - extremes = [], - timezone = "UTC", - units, - locale, - className, -}: TideGraphDataProps & { locale: string; className?: string }) { - const { ref: containerRef, width: containerWidth } = useContainerWidth(); - return ( -
    - {containerWidth > 0 && ( - - )} -
    - ); -} - -// ─── Public component ─────────────────────────────────────────────────────── - -export function TideGraph(props: TideGraphProps) { - const config = useNeapsConfig(); - - if (props.timeline) { - return ( - - ); - } - - return ( - - ); -} diff --git a/packages/react/src/components/TideGraph/NightBands.tsx b/packages/react/src/components/TideGraph/NightBands.tsx new file mode 100644 index 00000000..fcd8d124 --- /dev/null +++ b/packages/react/src/components/TideGraph/NightBands.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import type { TideXScale } from "../../utils/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.stories.tsx b/packages/react/src/components/TideGraph/TideGraph.stories.tsx similarity index 96% rename from packages/react/src/components/TideGraph.stories.tsx rename to packages/react/src/components/TideGraph/TideGraph.stories.tsx index d059fd26..4f8f4b2c 100644 --- a/packages/react/src/components/TideGraph.stories.tsx +++ b/packages/react/src/components/TideGraph/TideGraph.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { NeapsProvider } from "../provider.js"; +import { NeapsProvider } from "../../provider.js"; import { TideGraph } from "./TideGraph.js"; const meta: Meta = { diff --git a/packages/react/src/components/TideGraph/TideGraph.tsx b/packages/react/src/components/TideGraph/TideGraph.tsx new file mode 100644 index 00000000..1a14428c --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraph.tsx @@ -0,0 +1,48 @@ +import { useNeapsConfig } from "../../provider.js"; +import { TideGraphScroll } from "./TideGraphScroll.js"; +import { TideGraphStatic } from "./TideGraphStatic.js"; +import { PX_PER_DAY_DEFAULT } from "./constants.js"; +import type { TimelineEntry, Extreme, Units } from "../../types.js"; + +export interface TideGraphDataProps { + timeline: TimelineEntry[]; + extremes?: Extreme[]; + timezone?: string; + units?: Units; +} + +export interface TideGraphFetchProps { + id: string; + timeline?: undefined; +} + +export type TideGraphProps = (TideGraphDataProps | TideGraphFetchProps) & { + pxPerDay?: number; + className?: string; +}; + +export function TideGraph(props: TideGraphProps) { + const config = useNeapsConfig(); + + if (props.timeline) { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/react/src/components/TideGraph/TideGraphChart.tsx b/packages/react/src/components/TideGraph/TideGraphChart.tsx new file mode 100644 index 00000000..f4fc1c53 --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraphChart.tsx @@ -0,0 +1,348 @@ +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 "../../utils/scales.js"; +import { NightBands } from "./NightBands.js"; +import { HEIGHT, MARGIN } from "./constants.js"; +import type { TimelineEntry, Extreme, Units } from "../../types.js"; + +const timelineBisector = bisector((d) => new Date(d.time).getTime()).left; + +export function TideGraphChart({ + timeline, + extremes, + timezone, + units, + locale, + svgWidth, + yDomainOverride, + latitude, + longitude, + className, + activeEntry, + onSelect, +}: { + timeline: TimelineEntry[]; + extremes: Extreme[]; + timezone: string; + units: Units; + locale: string; + 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 { 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 - new Date(d0.time).getTime() > new Date(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]); + + if (innerW <= 0 || svgWidth <= 0) return null; + + return ( + + + + + + + + + + + + + + + + + + + + + + {/* Zero reference line */} + + + {/* Area fill: positive (above zero) */} + xScale(new Date(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(new Date(d.time).getTime())} + y={(d) => yScale(d.level)} + yScale={zeroBaseScale} + curve={curveNatural} + fill={`url(#${gradientId}-neg)`} + clipPath={`url(#${gradientId}-clip-neg)`} + /> + xScale(new Date(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(new Date(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 [start, end] = xScale.domain(); + 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); + } + const fmt = new Intl.DateTimeFormat(locale, { timeZone: timezone, month: "short" }); + const months = dates.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(new Date(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/TideGraphScroll.tsx b/packages/react/src/components/TideGraph/TideGraphScroll.tsx new file mode 100644 index 00000000..9ae417eb --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraphScroll.tsx @@ -0,0 +1,273 @@ +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 "../../utils/scales.js"; +import { TideGraphChart } from "./TideGraphChart.js"; +import { YAxisOverlay } from "./YAxisOverlay.js"; +import { HEIGHT, MARGIN, MS_PER_DAY } from "./constants.js"; +import type { TimelineEntry } from "../../types.js"; + +export function TideGraphScroll({ + id, + pxPerDay, + locale, + className, +}: { + id: string; + pxPerDay: number; + locale: string; + className?: string; +}) { + 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 ? new Date(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) + useLayoutEffect(() => { + 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 = new Date(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 = new Date(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/TideGraphStatic.tsx b/packages/react/src/components/TideGraph/TideGraphStatic.tsx new file mode 100644 index 00000000..7abafd0f --- /dev/null +++ b/packages/react/src/components/TideGraph/TideGraphStatic.tsx @@ -0,0 +1,48 @@ +import { useCallback } from "react"; +import { useTooltip } from "@visx/tooltip"; + +import { useContainerWidth } from "../../hooks/use-container-width.js"; +import { useCurrentLevel } from "../../hooks/use-current-level.js"; +import { TideGraphChart } from "./TideGraphChart.js"; +import type { TideGraphDataProps } from "./TideGraph.js"; +import type { TimelineEntry } from "../../types.js"; + +export function TideGraphStatic({ + timeline, + extremes = [], + timezone = "UTC", + units = "feet", + locale, + className, +}: TideGraphDataProps & { locale: string; className?: string }) { + const { ref: containerRef, width: containerWidth } = useContainerWidth(); + const currentLevel = useCurrentLevel(timeline); + const { tooltipData, showTooltip, hideTooltip } = useTooltip(); + + const activeEntry = tooltipData ?? currentLevel; + + const handleSelect = useCallback( + (entry: TimelineEntry | null) => { + if (entry) showTooltip({ tooltipData: entry }); + else hideTooltip(); + }, + [showTooltip, hideTooltip], + ); + + return ( +
    + {containerWidth > 0 && ( + + )} +
    + ); +} diff --git a/packages/react/src/components/TideGraph/YAxisOverlay.tsx b/packages/react/src/components/TideGraph/YAxisOverlay.tsx new file mode 100644 index 00000000..74b22575 --- /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 "../../utils/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..5f5fe5db --- /dev/null +++ b/packages/react/src/components/TideGraph/constants.ts @@ -0,0 +1,6 @@ +import type { Margin } from "../../utils/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..9f8587a4 --- /dev/null +++ b/packages/react/src/components/TideGraph/index.ts @@ -0,0 +1,6 @@ +export * from "./TideGraph.js"; +export * from "./TideGraphChart.js"; +export * from "./TideGraphScroll.js"; +export * from "./TideGraphStatic.js"; +export * from "./NightBands.js"; +export * from "./YAxisOverlay.js"; diff --git a/packages/react/src/components/TideStation.tsx b/packages/react/src/components/TideStation.tsx index 99de0f7e..f28d630c 100644 --- a/packages/react/src/components/TideStation.tsx +++ b/packages/react/src/components/TideStation.tsx @@ -5,7 +5,7 @@ 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.js"; +import { TideGraph } from "./TideGraph/index.js"; import { TideTable } from "./TideTable.js"; import type { Units } from "../types.js"; import { TideStationHeader } from "./TideStationHeader.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/index.ts b/packages/react/src/index.ts index 8d01b667..bc2a325a 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -19,7 +19,7 @@ export * from "./components/TideStationHeader.js"; export * from "./components/TideStation.js"; export * from "./components/TideConditions.js"; export * from "./components/TideCycleGraph.js"; -export * from "./components/TideGraph.js"; +export * from "./components/TideGraph/index.js"; export * from "./components/TideTable.js"; export * from "./components/StationDisclaimers.js"; export * from "./components/TideSettings.js"; diff --git a/packages/react/src/utils/scales.ts b/packages/react/src/utils/scales.ts index 93a4d8bc..4b3656ac 100644 --- a/packages/react/src/utils/scales.ts +++ b/packages/react/src/utils/scales.ts @@ -59,3 +59,7 @@ export function useTideScales({ 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/test/a11y.test.tsx b/packages/react/test/a11y.test.tsx index c3eaddf9..1810af09 100644 --- a/packages/react/test/a11y.test.tsx +++ b/packages/react/test/a11y.test.tsx @@ -5,7 +5,7 @@ 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 { TideGraph } from "../src/components/TideGraph.js"; +import { TideGraph } from "../src/components/TideGraph/index.js"; import { createTestWrapper } from "./helpers.js"; async function checkA11y(container: HTMLElement) { diff --git a/packages/react/test/components/TideGraph.test.tsx b/packages/react/test/components/TideGraph.test.tsx index 09d63f6d..4f9256c6 100644 --- a/packages/react/test/components/TideGraph.test.tsx +++ b/packages/react/test/components/TideGraph.test.tsx @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { render, waitFor } from "@testing-library/react"; -import { TideGraph } from "../../src/components/TideGraph.js"; +import { TideGraph } from "../../src/components/TideGraph/index.js"; import { createTestWrapper } from "../helpers.js"; const timeline = [ From 70a7185ed096ebfd7f9f31d2544e662bc451d06d Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 1 Mar 2026 21:12:56 -0500 Subject: [PATCH 19/48] Rearrange imports/exports --- .../react/src/components/TideCycleGraph.tsx | 2 +- .../src/components/TideGraph/NightBands.tsx | 2 +- .../components/TideGraph/TideGraphChart.tsx | 104 +++++++++--------- .../components/TideGraph/TideGraphScroll.tsx | 8 +- .../src/components/TideGraph/YAxisOverlay.tsx | 2 +- .../src/components/TideGraph/constants.ts | 2 +- packages/react/src/components/TideStation.tsx | 5 +- packages/react/src/components/index.ts | 11 ++ packages/react/src/hooks/index.ts | 12 ++ .../scales.ts => hooks/use-tide-scales.ts} | 0 packages/react/src/index.ts | 54 +-------- 11 files changed, 85 insertions(+), 117 deletions(-) create mode 100644 packages/react/src/components/index.ts create mode 100644 packages/react/src/hooks/index.ts rename packages/react/src/{utils/scales.ts => hooks/use-tide-scales.ts} (100%) diff --git a/packages/react/src/components/TideCycleGraph.tsx b/packages/react/src/components/TideCycleGraph.tsx index e50a9a6f..5da82c97 100644 --- a/packages/react/src/components/TideCycleGraph.tsx +++ b/packages/react/src/components/TideCycleGraph.tsx @@ -4,7 +4,7 @@ import { Group } from "@visx/group"; import { curveNatural } from "@visx/curve"; import { interpolateLevel } from "../hooks/use-current-level.js"; -import { useTideScales, type Margin } from "../utils/scales.js"; +import { useTideScales, type Margin } from "../hooks/use-tide-scales.js"; import type { Extreme, TimelineEntry } from "../types.js"; const HALF_WINDOW_MS = 6.417 * 60 * 60 * 1000; diff --git a/packages/react/src/components/TideGraph/NightBands.tsx b/packages/react/src/components/TideGraph/NightBands.tsx index fcd8d124..6da3fed6 100644 --- a/packages/react/src/components/TideGraph/NightBands.tsx +++ b/packages/react/src/components/TideGraph/NightBands.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import type { TideXScale } from "../../utils/scales.js"; +import type { TideXScale } from "../../hooks/use-tide-scales.js"; import { getNightIntervals } from "../../utils/sun.js"; import { HEIGHT, MARGIN } from "./constants.js"; diff --git a/packages/react/src/components/TideGraph/TideGraphChart.tsx b/packages/react/src/components/TideGraph/TideGraphChart.tsx index f4fc1c53..ddf491f1 100644 --- a/packages/react/src/components/TideGraph/TideGraphChart.tsx +++ b/packages/react/src/components/TideGraph/TideGraphChart.tsx @@ -7,7 +7,7 @@ import { localPoint } from "@visx/event"; import { bisector } from "d3-array"; import { formatLevel, formatTime } from "../../utils/format.js"; -import { useTideScales } from "../../utils/scales.js"; +import { useTideScales } from "../../hooks/use-tide-scales.js"; import { NightBands } from "./NightBands.js"; import { HEIGHT, MARGIN } from "./constants.js"; import type { TimelineEntry, Extreme, Units } from "../../types.js"; @@ -227,12 +227,7 @@ export function TideGraphChart({ > {formatLevel(e.level, units)} - + {formatTime(e.time, timezone, locale)} @@ -295,53 +290,54 @@ export function TideGraphChart({ /> {/* Active entry annotation */} - {activeEntry && (() => { - const cx = xScale(new Date(activeEntry.time).getTime()); - const labelY = innerH / 2; - return ( - - - - - {formatTime(activeEntry.time, timezone, locale)} - - - {formatLevel(activeEntry.level, units)} - - - ); - })()} + {activeEntry && + (() => { + const cx = xScale(new Date(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/TideGraphScroll.tsx b/packages/react/src/components/TideGraph/TideGraphScroll.tsx index 9ae417eb..51727f0b 100644 --- a/packages/react/src/components/TideGraph/TideGraphScroll.tsx +++ b/packages/react/src/components/TideGraph/TideGraphScroll.tsx @@ -3,7 +3,7 @@ import { useTooltip } from "@visx/tooltip"; import { useTideChunks } from "../../hooks/use-tide-chunks.js"; import { useCurrentLevel } from "../../hooks/use-current-level.js"; -import { useTideScales } from "../../utils/scales.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 } from "./constants.js"; @@ -251,11 +251,7 @@ export function TideGraphScroll({
    {/* 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/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/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/utils/scales.ts b/packages/react/src/hooks/use-tide-scales.ts similarity index 100% rename from packages/react/src/utils/scales.ts rename to packages/react/src/hooks/use-tide-scales.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index bc2a325a..60a83dd6 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,52 +1,8 @@ -// Provider -export { NeapsProvider, useNeapsConfig, useUpdateConfig } from "./provider.js"; -export type { NeapsProviderProps, NeapsConfig, NeapsConfigUpdater } from "./provider.js"; - -// Hooks -export { useStation } from "./hooks/use-station.js"; -export { useStations } from "./hooks/use-stations.js"; -export { useExtremes } from "./hooks/use-extremes.js"; -export type { UseExtremesParams } from "./hooks/use-extremes.js"; -export { useTimeline } from "./hooks/use-timeline.js"; -export type { UseTimelineParams } from "./hooks/use-timeline.js"; -export { useNearbyStations } from "./hooks/use-nearby-stations.js"; -export type { UseNearbyStationsParams } from "./hooks/use-nearby-stations.js"; -export { useThemeColors, withAlpha } from "./hooks/use-theme-colors.js"; -export type { ThemeColors } from "./hooks/use-theme-colors.js"; - -// Components -export * from "./components/TideStationHeader.js"; -export * from "./components/TideStation.js"; -export * from "./components/TideConditions.js"; -export * from "./components/TideCycleGraph.js"; -export * from "./components/TideGraph/index.js"; -export * from "./components/TideTable.js"; -export * from "./components/StationDisclaimers.js"; -export * from "./components/TideSettings.js"; -export * from "./components/StationSearch.js"; -export * from "./components/NearbyStations.js"; -export * from "./components/StationsMap.js"; - -// Client -export { - fetchExtremes, - fetchTimeline, - fetchStation, - fetchStations, - fetchStationExtremes, - fetchStationTimeline, -} from "./client.js"; - -// Types -export type { - Units, - Station, - StationSummary, - Extreme, - TimelineEntry, - ExtremesResponse, - TimelineResponse, -} from "./types.js"; +export * from "./types.js"; +export * from "./provider.js"; +export * from "./client.js"; +export * from "./hooks/index.js"; +export * from "./components/index.js"; // Utilities export { formatLevel, formatTime, formatDate, formatDistance } from "./utils/format.js"; From 25d9d67682ec043fd60ded0a9eecd966052f06d8 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 2 Mar 2026 06:53:41 -0500 Subject: [PATCH 20/48] Convert timestamps to Date objects in API client --- packages/react/src/client.ts | 44 ++++++++++--- .../react/src/components/StationSearch.tsx | 2 +- packages/react/src/components/StationsMap.tsx | 2 +- .../react/src/components/TideConditions.tsx | 8 +-- .../react/src/components/TideCycleGraph.tsx | 12 ++-- .../components/TideGraph/TideGraphChart.tsx | 16 ++--- .../components/TideGraph/TideGraphScroll.tsx | 6 +- packages/react/src/components/TideTable.tsx | 4 +- packages/react/src/hooks/use-current-level.ts | 8 +-- packages/react/src/hooks/use-tide-chunks.ts | 18 ++--- packages/react/src/hooks/use-tide-scales.ts | 2 +- packages/react/src/types.ts | 65 ++++++------------- packages/react/src/utils/format.ts | 16 ++--- packages/react/test/a11y.test.tsx | 14 ++-- .../react/test/components/TideGraph.test.tsx | 20 +++--- .../react/test/components/TideTable.test.tsx | 8 +-- packages/react/test/format.test.ts | 10 +-- .../react/test/hooks/use-timeline.test.tsx | 2 +- 18 files changed, 130 insertions(+), 127 deletions(-) diff --git a/packages/react/src/client.ts b/packages/react/src/client.ts index 13885cc9..ecf70593 100644 --- a/packages/react/src/client.ts +++ b/packages/react/src/client.ts @@ -56,12 +56,34 @@ function parseStationId(id: string): { source: string; stationId: string } { return { source: id.slice(0, slash), stationId: id.slice(slash + 1) }; } -export function fetchExtremes(baseUrl: string, params: LocationParams): Promise { - return fetchJSON(buildURL(baseUrl, "/tides/extremes", params)); +/** 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 function fetchTimeline(baseUrl: string, params: LocationParams): Promise { - return fetchJSON(buildURL(baseUrl, "/tides/timeline", params)); +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 { @@ -76,20 +98,26 @@ export function fetchStations( return fetchJSON(buildURL(baseUrl, "/tides/stations", params)); } -export function fetchStationExtremes( +export async function fetchStationExtremes( baseUrl: string, params: StationPredictionParams, ): Promise { const { id, ...rest } = params; const { source, stationId } = parseStationId(id); - return fetchJSON(buildURL(baseUrl, `/tides/stations/${source}/${stationId}/extremes`, rest)); + 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 function fetchStationTimeline( +export async function fetchStationTimeline( baseUrl: string, params: StationPredictionParams, ): Promise { const { id, ...rest } = params; const { source, stationId } = parseStationId(id); - return fetchJSON(buildURL(baseUrl, `/tides/stations/${source}/${stationId}/timeline`, rest)); + 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/StationSearch.tsx b/packages/react/src/components/StationSearch.tsx index 96783001..684f14c2 100644 --- a/packages/react/src/components/StationSearch.tsx +++ b/packages/react/src/components/StationSearch.tsx @@ -9,7 +9,7 @@ const MAX_RECENT = 5; interface RecentSearch { id: string; name: string; - region: string; + region?: string; country: string; } diff --git a/packages/react/src/components/StationsMap.tsx b/packages/react/src/components/StationsMap.tsx index 38cd85ea..cd743c80 100644 --- a/packages/react/src/components/StationsMap.tsx +++ b/packages/react/src/components/StationsMap.tsx @@ -71,7 +71,7 @@ function stationsToGeoJSON(stations: StationSummary[]): GeoJSON.FeatureCollectio function getNextExtreme(extremes: Extreme[]): Extreme | null { const now = new Date(); - return extremes.find((e) => new Date(e.time) > now) ?? null; + return extremes.find((e) => e.time > now) ?? null; } function StationPreviewCard({ stationId }: { stationId: string }) { diff --git a/packages/react/src/components/TideConditions.tsx b/packages/react/src/components/TideConditions.tsx index 137d5a42..98af7325 100644 --- a/packages/react/src/components/TideConditions.tsx +++ b/packages/react/src/components/TideConditions.tsx @@ -14,7 +14,7 @@ export interface TideConditionsProps { function getNextExtreme(extremes: Extreme[]): Extreme | null { const now = new Date(); - return extremes.find((e) => new Date(e.time) > now) ?? null; + return extremes.find((e) => e.time > now) ?? null; } function isTideRising(extremes: Extreme[]): boolean | null { @@ -43,7 +43,7 @@ export function WaterLevelAtTime({ }: { label: string; level: number; - time: string; + time: Date; units: Units; locale: string; state?: TideState; @@ -64,7 +64,7 @@ export function WaterLevelAtTime({ )} - {new Date(time).toLocaleString(locale, { + {time.toLocaleString(locale, { timeStyle: "short", })} @@ -98,7 +98,7 @@ export function TideConditions({

    - {new Date(currentLevel.time).toLocaleString(locale, { + {currentLevel.time.toLocaleString(locale, { dateStyle: "medium", timeZone: timezone, })} diff --git a/packages/react/src/components/TideCycleGraph.tsx b/packages/react/src/components/TideCycleGraph.tsx index 5da82c97..768ac88f 100644 --- a/packages/react/src/components/TideCycleGraph.tsx +++ b/packages/react/src/components/TideCycleGraph.tsx @@ -16,7 +16,7 @@ export interface TideCycleGraphProps { className?: string; } -const getX = (d: TimelineEntry) => new Date(d.time).getTime(); +const getX = (d: TimelineEntry) => d.time.getTime(); const getY = (d: TimelineEntry) => d.level; function TideCycleGraphChart({ @@ -84,7 +84,7 @@ function TideCycleGraphChart({ {extremes.map((e, i) => ( timeline.filter((e) => { - const t = new Date(e.time).getTime(); + const t = e.time.getTime(); return t >= windowStart && t <= windowEnd; }), [timeline, windowStart, windowEnd], @@ -148,7 +148,7 @@ export function TideCycleGraph({ timeline, extremes, className }: TideCycleGraph const windowExtremes = useMemo( () => extremes.filter((e) => { - const t = new Date(e.time).getTime(); + const t = e.time.getTime(); return t >= windowStart && t <= windowEnd; }), [extremes, windowStart, windowEnd], diff --git a/packages/react/src/components/TideGraph/TideGraphChart.tsx b/packages/react/src/components/TideGraph/TideGraphChart.tsx index ddf491f1..bfcd410d 100644 --- a/packages/react/src/components/TideGraph/TideGraphChart.tsx +++ b/packages/react/src/components/TideGraph/TideGraphChart.tsx @@ -12,7 +12,7 @@ import { NightBands } from "./NightBands.js"; import { HEIGHT, MARGIN } from "./constants.js"; import type { TimelineEntry, Extreme, Units } from "../../types.js"; -const timelineBisector = bisector((d) => new Date(d.time).getTime()).left; +const timelineBisector = bisector((d) => d.time.getTime()).left; export function TideGraphChart({ timeline, @@ -61,7 +61,7 @@ export function TideGraphChart({ const d0 = timeline[idx - 1]; const d1 = timeline[idx]; if (!d0) return null; - return d1 && x0 - new Date(d0.time).getTime() > new Date(d1.time).getTime() - x0 ? d1 : d0; + return d1 && x0 - d0.time.getTime() > d1.time.getTime() - x0 ? d1 : d0; }, [xScale, timeline], ); @@ -146,7 +146,7 @@ export function TideGraphChart({ {/* Area fill: positive (above zero) */} xScale(new Date(d.time).getTime())} + x={(d) => xScale(d.time.getTime())} y={(d) => yScale(d.level)} yScale={zeroBaseScale} curve={curveNatural} @@ -156,7 +156,7 @@ export function TideGraphChart({ {/* Area fill: negative (below zero) */} xScale(new Date(d.time).getTime())} + x={(d) => xScale(d.time.getTime())} y={(d) => yScale(d.level)} yScale={zeroBaseScale} curve={curveNatural} @@ -165,7 +165,7 @@ export function TideGraphChart({ /> xScale(new Date(d.time).getTime())} + x={(d) => xScale(d.time.getTime())} y={(d) => yScale(d.level)} curve={curveNatural} stroke="var(--neaps-primary)" @@ -174,10 +174,10 @@ export function TideGraphChart({ {/* Extreme points + labels */} {extremes.map((e) => { - const cx = xScale(new Date(e.time).getTime()); + const cx = xScale(e.time.getTime()); const cy = yScale(e.level); return ( - + { - const cx = xScale(new Date(activeEntry.time).getTime()); + const cx = xScale(activeEntry.time.getTime()); const labelY = innerH / 2; return ( diff --git a/packages/react/src/components/TideGraph/TideGraphScroll.tsx b/packages/react/src/components/TideGraph/TideGraphScroll.tsx index 51727f0b..4bae3c27 100644 --- a/packages/react/src/components/TideGraph/TideGraphScroll.tsx +++ b/packages/react/src/components/TideGraph/TideGraphScroll.tsx @@ -81,7 +81,7 @@ export function TideGraphScroll({ ); // Position of "now" in SVG coordinates (for today-button visibility) - const nowMs = currentLevel ? new Date(currentLevel.time).getTime() : null; + const nowMs = currentLevel ? currentLevel.time.getTime() : null; const nowPx = useMemo(() => { if (nowMs === null) return null; return ((nowMs - dataStart) / totalMs) * innerW + MARGIN.left; @@ -149,7 +149,7 @@ export function TideGraphScroll({ const w = container!.clientWidth; if (pinnedEntry && nowMs !== null) { - const pinnedMs = new Date(pinnedEntry.time).getTime(); + const pinnedMs = pinnedEntry.time.getTime(); setTodayDirection(pinnedMs < nowMs ? "right" : "left"); } else if (nowPx !== null) { const nowVx = nowPx - sl; @@ -162,7 +162,7 @@ export function TideGraphScroll({ // Clear pinned entry when it scrolls far out of view if (pinnedEntry) { - const pinnedMs = new Date(pinnedEntry.time).getTime(); + const pinnedMs = pinnedEntry.time.getTime(); const pinnedPx = ((pinnedMs - dataStart) / totalMs) * innerW + MARGIN.left; const pvx = pinnedPx - sl; if (pvx < -w || pvx > 2 * w) { diff --git a/packages/react/src/components/TideTable.tsx b/packages/react/src/components/TideTable.tsx index 6c395d69..9d607cbc 100644 --- a/packages/react/src/components/TideTable.tsx +++ b/packages/react/src/components/TideTable.tsx @@ -75,12 +75,12 @@ function TideTableView({