+
{JSON.stringify(exampleResponse, null, 2)}
diff --git a/website/src/components/layout/Header.astro b/website/src/components/layout/Header.astro
index f293cac..044d796 100644
--- a/website/src/components/layout/Header.astro
+++ b/website/src/components/layout/Header.astro
@@ -15,7 +15,8 @@ const currentPath = Astro.url.pathname;
@@ -58,7 +64,8 @@ const currentPath = Astro.url.pathname;
)}
{chartData.label && (
-
+
{chartData.label}
)}
diff --git a/website/src/components/tides/TideGraph.tsx b/website/src/components/tides/TideGraph.tsx
deleted file mode 100644
index e60602f..0000000
--- a/website/src/components/tides/TideGraph.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import { useMemo } from "react";
-import {
- Chart as ChartJS,
- CategoryScale,
- LinearScale,
- PointElement,
- LineElement,
- Title,
- Tooltip,
- Legend,
- Filler,
- TimeScale,
-} from "chart.js";
-import { Line } from "react-chartjs-2";
-import "chartjs-adapter-date-fns";
-import type { Station } from "@neaps/tide-database";
-import type { ExtremesResponse, TimelineResponse } from "../../utils/tides";
-
-ChartJS.register(
- CategoryScale,
- LinearScale,
- PointElement,
- LineElement,
- Title,
- Tooltip,
- Legend,
- Filler,
- TimeScale,
-);
-
-interface Props {
- station: Station;
- extremesData: ExtremesResponse | null;
- timelineData: TimelineResponse | null;
-}
-
-export function TideGraph({ station, extremesData, timelineData }: Props) {
- const units = timelineData?.units || extremesData?.units || "meters";
-
- const timelinePoints = useMemo(() => {
- if (!timelineData) return [];
- return timelineData.timeline.map((p) => ({
- time: new Date(p.time),
- level: p.level,
- }));
- }, [timelineData]);
-
- const extremePoints = useMemo(() => {
- if (!extremesData) return [];
- return extremesData.extremes.map((p) => ({
- time: new Date(p.time),
- level: p.level,
- label: p.label,
- }));
- }, [extremesData]);
-
- const datum =
- timelineData?.datum || extremesData?.datum || station.chart_datum;
-
- const minLevel =
- timelinePoints.length > 0
- ? Math.min(...timelinePoints.map((d) => d.level))
- : 0;
- const maxLevel =
- timelinePoints.length > 0
- ? Math.max(...timelinePoints.map((d) => d.level))
- : 2;
- const padding = (maxLevel - minLevel) * 0.1 || 0.5;
-
- const pointDateStyle = Intl.DateTimeFormat(undefined, {
- timeZone: station.timezone,
- dateStyle: "medium",
- timeStyle: "short",
- });
- const axisDateStyle = Intl.DateTimeFormat(undefined, {
- timeZone: station.timezone,
- weekday: "short",
- month: "short",
- day: "numeric",
- });
-
- const currentTime = new Date();
- const chartData = {
- datasets: [
- {
- label: "High/Low",
- data: extremePoints.map((d) => ({ x: d.time, y: d.level })),
- borderColor: "transparent",
- backgroundColor: "#0284c7",
- borderWidth: 0,
- fill: false,
- pointRadius: 5,
- pointHoverRadius: 7,
- pointStyle: "circle" as const,
- showLine: false,
- },
- {
- label: "Water Level",
- data: timelinePoints.map((d) => ({ x: d.time, y: d.level })),
- borderColor: "#0ea5e9",
- backgroundColor: "rgba(14, 165, 233, 0.1)",
- borderWidth: 2,
- fill: true,
- tension: 0.4,
- pointRadius: 0,
- pointHoverRadius: 5,
- pointHoverBackgroundColor: "#0ea5e9",
- },
- {
- label: "Current Time",
- data: [
- { x: currentTime, y: Math.min(0, minLevel - padding) },
- { x: currentTime, y: maxLevel + padding },
- ],
- borderColor: "rgba(249, 115, 22, 0.5)",
- borderWidth: 2,
- pointRadius: 0,
- fill: false,
- },
- ],
- };
-
- const unitsLabel = units === "feet" ? "ft" : "m";
-
- const chartOptions = {
- responsive: true,
- interaction: {
- mode: "index" as const,
- intersect: false,
- },
- plugins: {
- legend: {
- display: false,
- },
- tooltip: {
- displayColors: false,
- filter: (item: any) => item.dataset.label !== "Current Time",
- callbacks: {
- title: (context: any) => {
- if (context.length === 0) return "";
- return pointDateStyle.format(new Date(context[0].parsed.x));
- },
- label: (context: any) => {
- const value = `${(context.parsed.y ?? 0).toFixed(2)} ${unitsLabel}`;
- if (context.dataset.label === "High/Low") {
- const extreme = extremePoints[context.dataIndex];
- return extreme?.label ? `${extreme.label}: ${value}` : value;
- }
- return value;
- },
- },
- },
- },
- scales: {
- x: {
- type: "time" as const,
- time: {
- unit: "day" as const,
- },
- ticks: {
- callback: function (value: any) {
- return axisDateStyle.format(new Date(value));
- },
- },
- },
- y: {
- ticks: {
- stepSize: maxLevel - minLevel > 3 ? 1 : 0.5,
- callback: function (value: any) {
- return `${value} ${unitsLabel}`;
- },
- },
- title: {
- display: true,
- text: datum,
- },
- },
- },
- };
-
- return (
-
-
- {timelinePoints.length > 0 ? (
-
-
-
- ) : (
-
- )}
-
-
- );
-}
diff --git a/website/src/components/tides/TideStation.tsx b/website/src/components/tides/TideStation.tsx
deleted file mode 100644
index 488682a..0000000
--- a/website/src/components/tides/TideStation.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useMemo } from "react";
-import type { Station } from "@neaps/tide-database";
-import { Temporal } from "@js-temporal/polyfill";
-import { useNeapsAPI } from "../../utils/useNeapsAPI";
-import type { ExtremesResponse, TimelineResponse } from "../../utils/tides";
-import { preferredUnits } from "../../utils/units";
-import Today from "./Today";
-import { TideGraph } from "./TideGraph";
-
-interface Props {
- station: Station;
-}
-
-export function TideStation({ station }: Props) {
- // Compute a single date range that covers both components:
- // - Today needs extremes from -6.5h to +18.5h
- // - TideGraph needs data from now to +3 days
- // Combined: -6.5h to +3 days
- const { startDate, endDate } = useMemo(() => {
- const start = Temporal.Now.zonedDateTimeISO(station.timezone).subtract({
- hours: 6,
- minutes: 30,
- });
- const end = Temporal.Now.zonedDateTimeISO(station.timezone).add({
- days: 3,
- });
- return {
- startDate: start.toInstant().toString(),
- endDate: end.toInstant().toString(),
- };
- }, [station.timezone]);
-
- const { data: extremesData, loading } = useNeapsAPI
(
- `/tides/stations/${station.id}/extremes`,
- { start: startDate, end: endDate, units: preferredUnits },
- );
-
- const { data: timelineData } = useNeapsAPI(
- `/tides/stations/${station.id}/timeline`,
- { start: startDate, end: endDate, units: preferredUnits },
- );
-
- if (loading) {
- return (
-
- );
- }
-
- return (
-
-
-
-
- );
-}
diff --git a/website/src/components/tides/TideStationIsland.tsx b/website/src/components/tides/TideStationIsland.tsx
new file mode 100644
index 0000000..49cbe48
--- /dev/null
+++ b/website/src/components/tides/TideStationIsland.tsx
@@ -0,0 +1,49 @@
+import { Component, type ReactNode, useMemo } from "react";
+import { NeapsProvider, TideStation, createQueryClient } from "@neaps/react";
+import { hydrate, type DehydratedState } from "@tanstack/react-query";
+import "@neaps/react/styles.css";
+import { API_HOST } from "../../utils/constants";
+
+class ErrorBoundary extends Component<
+ { children: ReactNode },
+ { error: Error | null }
+> {
+ state = { error: null };
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+ render() {
+ if (this.state.error) {
+ return (
+
+ Error loading tide station:{" "}
+ {(this.state.error as Error).message}
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+interface Props {
+ id: string;
+ dehydratedState?: DehydratedState;
+}
+
+export function TideStationIsland({ id, dehydratedState }: Props) {
+ const queryClient = useMemo(() => {
+ const client = createQueryClient();
+ if (dehydratedState) {
+ hydrate(client, dehydratedState);
+ }
+ return client;
+ }, [dehydratedState]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/website/src/components/tides/Today.tsx b/website/src/components/tides/Today.tsx
deleted file mode 100644
index 865b3c0..0000000
--- a/website/src/components/tides/Today.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-import { useMemo } from "react";
-import { DateTime } from "../DateTime";
-import { TideHeight } from "../TideHeight";
-import type { Station } from "@neaps/tide-database";
-import { Temporal } from "@js-temporal/polyfill";
-import { cn } from "../../utils/cn";
-import type { ExtremesResponse, TimelineResponse } from "../../utils/tides";
-
-// Format timezone name with UTC offset (e.g., "America/New_York" → "New York (UTC-5)")
-function formatTimezoneWithOffset(timeZone: string) {
- // Get the UTC offset for this timezone
- const zoned = Temporal.Now.zonedDateTimeISO(timeZone);
- const offset = zoned.offset;
-
- // Format offset as +/-HH:MM
- const sign = offset.startsWith("-") ? "-" : "+";
- const formatted =
- sign +
- offset
- .slice(1)
- .split(":")
- .filter((n) => n && n !== "00")
- .join(":");
-
- return `${timeZone.replace("_", " ")} (UTC${formatted})`;
-}
-
-export type TodayProps = {
- station: Station;
- extremesData: ExtremesResponse | null;
- timelineData: TimelineResponse | null;
- now?: Temporal.Instant;
-};
-
-export default function Today({
- station,
- extremesData,
- timelineData,
- now = Temporal.Now.instant(),
-}: TodayProps) {
- const start = useMemo(() => {
- return Temporal.Now.zonedDateTimeISO(station.timezone).subtract({
- hours: 6,
- minutes: 30,
- });
- }, [station.timezone]);
-
- const end = useMemo(() => start.add({ hours: 25 }), [start]);
-
- // Filter extremes to the ~25h window for today's display
- const extremes = useMemo(() => {
- if (!extremesData?.extremes) return [];
- const startMs = start.toInstant().epochMilliseconds;
- const endMs = end.toInstant().epochMilliseconds;
- return extremesData.extremes.filter((e) => {
- const t = new Date(e.time).valueOf();
- return t >= startMs && t <= endMs;
- });
- }, [extremesData, start, end]);
-
- const datum = extremesData?.datum || station.chart_datum;
-
- const nextTide = extremes.find(
- (extreme) => new Date(extreme.time).valueOf() > now.epochMilliseconds,
- );
-
- // Find the closest timeline point to now for current water level
- const nowLevel = useMemo(() => {
- if (!timelineData?.timeline?.length) return undefined;
- const nowMs = now.epochMilliseconds;
- let closest = timelineData.timeline[0];
- let closestDiff = Math.abs(new Date(closest.time).valueOf() - nowMs);
- for (const point of timelineData.timeline) {
- const diff = Math.abs(new Date(point.time).valueOf() - nowMs);
- if (diff < closestDiff) {
- closest = point;
- closestDiff = diff;
- }
- }
- return closest.level;
- }, [timelineData, now]);
-
- return (
-
-
-
-
Today's Tides
-
-
-
-
-
-
-
- {formatTimezoneWithOffset(station.timezone)}
-
-
- {nextTide?.high ? (
-
- ↑
-
- ) : (
-
- ↓
-
- )}
- {nowLevel != null && }
-
-
{datum}
-
- {extremes.length > 0 ? (
-
-
-
-
- |
- Time
- |
-
- Type
- |
-
- Height
- |
-
-
-
- {extremes.map((extreme) => (
-
- |
-
- |
- {extreme.label} |
-
-
- |
-
- ))}
-
-
-
- ) : (
-
-
No tide data available
-
- )}
-
-
- );
-}
diff --git a/website/src/components/ui/BottomDrawer.tsx b/website/src/components/ui/BottomDrawer.tsx
index ef20695..dcd394a 100644
--- a/website/src/components/ui/BottomDrawer.tsx
+++ b/website/src/components/ui/BottomDrawer.tsx
@@ -91,7 +91,7 @@ export function BottomDrawer({ children, onFocus }: BottomDrawerProps) {
return (
{/* Drawer handle */}
{/* Drawer content */}
diff --git a/website/src/components/ui/Feature.astro b/website/src/components/ui/Feature.astro
index 6587894..9f460cb 100644
--- a/website/src/components/ui/Feature.astro
+++ b/website/src/components/ui/Feature.astro
@@ -12,18 +12,18 @@ const { title, description, action, icon } = Astro.props;
---
-
+
{icon ? : }
{title}
-
+
{description}
{
action && (
-
+
{action}