{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tide-forecast",
  "type": "registry:component",
  "description": "Coastal tide predictions with a theme-aware chart.",
  "registryDependencies": [
    "card",
    "badge",
    "chart",
    "button"
  ],
  "dependencies": [
    "lucide-react",
    "@number-flow/react@^0.5.8"
  ],
  "files": [
    {
      "path": "packages/react/src/components/wxcn/tide-forecast.tsx",
      "type": "registry:component",
      "target": "@components/wxcn/tide-forecast.tsx",
      "content": "'use client';\n\nimport { useCallback, useEffect, useMemo, useState, type ComponentProps } from 'react';\nimport NumberFlow from '@number-flow/react';\nimport {\n\tArea,\n\tAreaChart,\n\tCartesianGrid,\n\tReferenceDot,\n\tReferenceLine,\n\tXAxis,\n\tYAxis\n} from 'recharts';\nimport type { IconSet } from './forecast-icons';\nimport { ForecastScreens, type ForecastAction, type OpenForecastDay } from './forecast-screens';\nimport { forecastDays, type ForecastDay } from '@/lib/wxcn/forecast-days.js';\nimport type {\n\tForecastType,\n\tLocationInput,\n\tTidePoint,\n\tTidePrediction,\n\tTideReading,\n\tTideUnit\n} from '@/lib/wxcn/types.js';\nimport { sampleTideSeries, sampleTideTime, sampleTides } from '@/lib/wxcn/tides.js';\nimport { tideState, tideTimestamp } from '@/lib/wxcn/tide-state.js';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart';\n\nexport type TideForecastProps = {\n\tinteractive?: boolean;\n\ticonType?: IconSet;\n\ttimeZone?: string;\n\ttype?: ForecastType;\n\tsize?: 'sm' | 'default' | 'lg';\n\tdensity?: 'compact' | 'comfortable';\n\tclassName?: string;\n\tunit?: TideUnit;\n\tlocation?: LocationInput;\n\tpredictions?: TidePrediction[];\n\texample?: boolean;\n\tseries?: TidePoint[];\n\treading?: TideReading | null;\n\tat?: number;\n\tsourceLabel?: string;\n};\n\nconst defaultLocation: LocationInput = {\n\tlabel: 'Galveston Pier 21, TX',\n\tlatitude: 29.31,\n\tlongitude: -94.7933,\n\tstation: '8771450',\n\ttimeZone: 'America/Chicago'\n};\n\nconst chartConfig = {\n\theight: { label: 'Tide level', color: 'var(--chart-1)' }\n};\ntype ChartPoint = { time: number; height: number };\n\nconst formatTime = (value: string | number, timeZone: string) =>\n\tnew Intl.DateTimeFormat('en-US', {\n\t\thour: 'numeric',\n\t\tminute: '2-digit',\n\t\ttimeZone\n\t}).format(typeof value === 'string' ? tideTimestamp(value) : value);\n\nconst formatDateTime = (value: string, timeZone: string) =>\n\tnew Intl.DateTimeFormat('en-US', {\n\t\tmonth: 'short',\n\t\tday: 'numeric',\n\t\thour: 'numeric',\n\t\tminute: '2-digit',\n\t\ttimeZone\n\t}).format(tideTimestamp(value));\n\nfunction TideTooltipContent({\n\tonHover,\n\tdisplayTimeZone,\n\tunit,\n\t...props\n}: ComponentProps<typeof ChartTooltipContent> & {\n\tonHover: (point: ChartPoint | null) => void;\n\tdisplayTimeZone: string;\n\tunit: TideUnit;\n}) {\n\tconst payloadPoint = props.payload?.[0]?.payload as Partial<ChartPoint> | undefined;\n\tuseEffect(() => {\n\t\tonHover(\n\t\t\tpayloadPoint?.time !== undefined && payloadPoint.height !== undefined\n\t\t\t\t? { time: payloadPoint.time, height: payloadPoint.height }\n\t\t\t\t: null\n\t\t);\n\t}, [onHover, payloadPoint?.height, payloadPoint?.time]);\n\tconst symbol = unit === 'meter' ? 'm' : 'ft';\n\tconst convertedHeight = (value: number) => (value * (unit === 'meter' ? 0.3048 : 1)).toFixed(1);\n\treturn (\n\t\t<ChartTooltipContent\n\t\t\t{...props}\n\t\t\tlabelFormatter={() =>\n\t\t\t\tpayloadPoint?.time === undefined\n\t\t\t\t\t? 'Tide level'\n\t\t\t\t\t: formatTime(payloadPoint.time, displayTimeZone)\n\t\t\t}\n\t\t\tformatter={(value) => `${convertedHeight(Number(value))} ${symbol}`}\n\t\t/>\n\t);\n}\n\nexport function TideForecast({\n\tinteractive = false,\n\ticonType,\n\ttimeZone,\n\ttype = 'summary',\n\tsize = 'default',\n\tdensity = 'comfortable',\n\tclassName = '',\n\tunit = 'ft',\n\tlocation = defaultLocation,\n\tpredictions = sampleTides,\n\texample,\n\tseries,\n\treading = null,\n\tat,\n\tsourceLabel = 'Sample tides'\n}: TideForecastProps) {\n\tconst [visitorTimeZone, setVisitorTimeZone] = useState('UTC');\n\tconst [clock, setClock] = useState<number | null>(null);\n\tconst [hovered, setHovered] = useState<ChartPoint | null>(null);\n\tconst [dayHovered, setDayHovered] = useState<ChartPoint | null>(null);\n\tuseEffect(() => {\n\t\tsetVisitorTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);\n\t\tsetClock(Date.now());\n\t\tconst timer = window.setInterval(() => setClock(Date.now()), 60_000);\n\t\treturn () => window.clearInterval(timer);\n\t}, []);\n\n\tconst displayTimeZone = timeZone ?? location.timeZone ?? visitorTimeZone;\n\tconst isSample = example ?? predictions === sampleTides;\n\tconst now = at ?? (isSample ? sampleTideTime : (clock ?? 0));\n\tconst tide = useMemo(\n\t\t() => tideState(predictions, series ?? (isSample ? sampleTideSeries : []), reading, now),\n\t\t[predictions, series, isSample, reading, now]\n\t);\n\tconst chartData = useMemo<ChartPoint[]>(() => {\n\t\tconst curveData = tide.points.filter(\n\t\t\t(point: ChartPoint) =>\n\t\t\t\tpoint.time >= now - 12 * 60 * 60 * 1000 && point.time <= now + 18 * 60 * 60 * 1000\n\t\t);\n\t\tif (tide.predicted !== null && !curveData.some((point: ChartPoint) => point.time === now)) {\n\t\t\treturn [...curveData, { time: now, height: tide.predicted }].sort(\n\t\t\t\t(a: ChartPoint, b: ChartPoint) => a.time - b.time\n\t\t\t);\n\t\t}\n\t\treturn curveData;\n\t}, [now, tide.points, tide.predicted]);\n\tconst domain = useMemo(() => {\n\t\tconst values = chartData.map((point: ChartPoint) => point.height);\n\t\tif (tide.level !== null) values.push(tide.level);\n\t\tif (!values.length) return [0, 1] as [number, number];\n\t\tconst low = Math.min(...values);\n\t\tconst high = Math.max(...values);\n\t\tconst padding = Math.max(0.15, (high - low) * 0.2);\n\t\treturn [low - padding, high + padding] as [number, number];\n\t}, [chartData, tide.level]);\n\tconst height = (value: number) => (value * (unit === 'meter' ? 0.3048 : 1)).toFixed(1);\n\tconst symbol = unit === 'meter' ? 'm' : 'ft';\n\tconst chartHeight = size === 'sm' ? 64 : size === 'lg' ? 144 : 112;\n\tconst handleHover = useCallback((point: ChartPoint | null) => setHovered(point), []);\n\tconst handleDayHover = useCallback((point: ChartPoint | null) => setDayHovered(point), []);\n\tconst days = useMemo(\n\t\t() =>\n\t\t\tforecastDays(\n\t\t\t\ttide.events\n\t\t\t\t\t.filter((event) => {\n\t\t\t\t\t\tconst eventTime = tideTimestamp(event.time);\n\t\t\t\t\t\treturn eventTime >= now && eventTime < now + 7 * 86400000;\n\t\t\t\t\t})\n\t\t\t\t\t.map((event) => ({\n\t\t\t\t\t\ttime: tideTimestamp(event.time),\n\t\t\t\t\t\tlabel: formatTime(event.time, displayTimeZone),\n\t\t\t\t\t\tsummary: `${event.type === 'H' ? 'High' : 'Low'} tide · ${height(Number(event.height))} ${symbol}`,\n\t\t\t\t\t\tdetails: `Predicted ${event.type === 'H' ? 'high' : 'low'} tide, ${height(Number(event.height))} ${symbol} above MLLW.`\n\t\t\t\t\t})),\n\t\t\t\tdisplayTimeZone\n\t\t\t),\n\t\t[now, tide.events, displayTimeZone, symbol, unit]\n\t);\n\tif (\n\t\t!isSample &&\n\t\t(predictions.length > 0 || (series?.length ?? 0) > 0) &&\n\t\tat === undefined &&\n\t\tclock === null\n\t) {\n\t\treturn (\n\t\t\t<Card\n\t\t\t\tstyle={{ containerType: 'inline-size' }}\n\t\t\t\tdata-density={density}\n\t\t\t\tdata-card-size={size}\n\t\t\t\tdata-size={size === 'sm' ? 'sm' : 'default'}\n\t\t\t\tclassName={`relative isolate min-w-0 overflow-hidden ${className}`}\n\t\t\t>\n\t\t\t\t<CardHeader>\n\t\t\t\t\t<CardTitle>Tides</CardTitle>\n\t\t\t\t\t<CardDescription>{location.label}</CardDescription>\n\t\t\t\t</CardHeader>\n\t\t\t\t<CardContent>\n\t\t\t\t\t<p role=\"status\" className=\"py-8 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\tLoading current tide time…\n\t\t\t\t\t</p>\n\t\t\t\t</CardContent>\n\t\t\t</Card>\n\t\t);\n\t}\n\tfunction pointsForDay(day: ForecastDay) {\n\t\tconst key = new Intl.DateTimeFormat('en-CA', {\n\t\t\ttimeZone: displayTimeZone,\n\t\t\tyear: 'numeric',\n\t\t\tmonth: '2-digit',\n\t\t\tday: '2-digit'\n\t\t});\n\t\treturn tide.points.filter((point) => key.format(point.time) === day.key);\n\t}\n\tfunction dailyDomain(points: ChartPoint[]): [number, number] {\n\t\tconst values = points.map((point) => point.height);\n\t\tif (!values.length) return [0, 1];\n\t\tconst low = Math.min(...values);\n\t\tconst high = Math.max(...values);\n\t\tconst padding = Math.max(0.15, (high - low) * 0.2);\n\t\treturn [low - padding, high + padding];\n\t}\n\tfunction cardView(\n\t\tday: ForecastDay | undefined,\n\t\taction: ForecastAction,\n\t\topenDay: OpenForecastDay\n\t) {\n\t\tconst events = day\n\t\t\t? tide.events.filter((event) =>\n\t\t\t\t\tday.entries.some((entry) => entry.time === tideTimestamp(event.time))\n\t\t\t\t)\n\t\t\t: predictions;\n\t\tconst viewNow = day ? day.entries[0].time : now;\n\t\tconst viewTide = day\n\t\t\t? tideState(events, series ?? (isSample ? sampleTideSeries : []), null, viewNow)\n\t\t\t: tide;\n\t\tconst viewChartData = day ? pointsForDay(day) : chartData;\n\t\tconst viewDomain = day ? dailyDomain(viewChartData) : domain;\n\t\tconst viewHovered = day\n\t\t\t? dayHovered && viewChartData.some((point) => point.time === dayHovered.time)\n\t\t\t\t? dayHovered\n\t\t\t\t: null\n\t\t\t: hovered;\n\t\tconst updateHover = day ? handleDayHover : handleHover;\n\t\tconst viewDisplayedLevel = viewHovered?.height ?? viewTide.level;\n\t\tconst viewDisplayedTime =\n\t\t\tviewHovered?.time ??\n\t\t\t(day ? viewNow : viewTide.observed ? tideTimestamp(viewTide.observed.time) : now);\n\t\tconst viewIndicatorTime = viewHovered?.time ?? (day ? viewDisplayedTime : now);\n\t\tconst viewIndicatorLevel = viewHovered?.height ?? viewTide.predicted;\n\t\tconst viewTimeParts = new Intl.DateTimeFormat('en-US', {\n\t\t\thour: 'numeric',\n\t\t\tminute: '2-digit',\n\t\t\ttimeZone: displayTimeZone\n\t\t}).formatToParts(viewDisplayedTime);\n\t\tconst neighbors = (\n\t\t\t<div\n\t\t\t\tdata-slot=\"tide-neighbors\"\n\t\t\t\tclassName={\n\t\t\t\t\tsize === 'sm' ? 'grid content-center gap-3' : 'grid grid-cols-2 gap-3 border-t pt-3'\n\t\t\t\t}\n\t\t\t>\n\t\t\t\t{[\n\t\t\t\t\t{ label: 'Previous', event: viewTide.previous },\n\t\t\t\t\t{ label: 'Next', event: viewTide.next }\n\t\t\t\t].map(({ label, event }) => (\n\t\t\t\t\t<div key={label}>\n\t\t\t\t\t\t<p\n\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\tsize === 'sm'\n\t\t\t\t\t\t\t\t\t? 'text-[10px] text-muted-foreground'\n\t\t\t\t\t\t\t\t\t: 'text-xs text-muted-foreground'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t{label} {event ? (event.type === 'H' ? 'high tide' : 'low tide') : 'tide'}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\tsize === 'sm' ? 'mt-0.5 flex flex-wrap items-baseline gap-x-2' : 'contents'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<p\n\t\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\tsize === 'sm'\n\t\t\t\t\t\t\t\t\t\t? 'text-xs font-medium tabular-nums'\n\t\t\t\t\t\t\t\t\t\t: 'mt-1 text-sm font-medium tabular-nums'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{event ? formatTime(event.time, displayTimeZone) : 'Unavailable'}\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t{event ? (\n\t\t\t\t\t\t\t\t<p\n\t\t\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\t\tsize === 'sm'\n\t\t\t\t\t\t\t\t\t\t\t? 'text-[10px] text-muted-foreground'\n\t\t\t\t\t\t\t\t\t\t\t: 'mt-1 text-xs text-muted-foreground'\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{height(Number(event.height))} {symbol}\n\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t))}\n\t\t\t</div>\n\t\t);\n\t\treturn (\n\t\t\t<>\n\t\t\t\t<CardHeader\n\t\t\t\t\tclassName={size === 'sm' ? 'flex items-baseline justify-between gap-2' : undefined}\n\t\t\t\t>\n\t\t\t\t\t<CardTitle>{day?.label ?? 'Tides'}</CardTitle>\n\t\t\t\t\t<CardDescription>{location.label}</CardDescription>\n\t\t\t\t\t{action(false)}\n\t\t\t\t</CardHeader>\n\t\t\t\t<CardContent\n\t\t\t\t\tclassName={`${size === 'sm' || density === 'compact' ? 'grid gap-3' : 'grid gap-5'}`}\n\t\t\t\t>\n\t\t\t\t\t{viewTide.events.length || viewTide.points.length ? (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<div className={`flex items-end justify-between gap-3 ${day ? '' : 'flex-wrap'}`}>\n\t\t\t\t\t\t\t\t<div className=\"min-w-0\">\n\t\t\t\t\t\t\t\t\t<p className={`mb-1 text-xs text-muted-foreground ${day ? 'truncate' : ''}`}>\n\t\t\t\t\t\t\t\t\t\t{viewHovered\n\t\t\t\t\t\t\t\t\t\t\t? 'Predicted water level'\n\t\t\t\t\t\t\t\t\t\t\t: !day && isSample\n\t\t\t\t\t\t\t\t\t\t\t\t? 'Example water level'\n\t\t\t\t\t\t\t\t\t\t\t\t: viewTide.observed\n\t\t\t\t\t\t\t\t\t\t\t\t\t? 'Current water level'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: viewTide.predicted !== null\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t? 'Predicted water level'\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t: 'High/low predictions only'}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p\n\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\tsize === 'sm'\n\t\t\t\t\t\t\t\t\t\t\t\t? { fontSize: 'clamp(1.25rem, 8cqw, 1.875rem)', lineHeight: 1.1 }\n\t\t\t\t\t\t\t\t\t\t\t\t: { fontSize: 'clamp(1.5rem, 12cqw, 2.5rem)' }\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tclassName={`${size === 'sm' ? 'text-3xl' : 'text-4xl'} font-medium tracking-tight tabular-nums`}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t{viewDisplayedLevel === null ? (\n\t\t\t\t\t\t\t\t\t\t\t'—'\n\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t<NumberFlow\n\t\t\t\t\t\t\t\t\t\t\t\tvalue={Number(height(viewDisplayedLevel))}\n\t\t\t\t\t\t\t\t\t\t\t\tformat={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}\n\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t<span className=\"ml-1 text-sm text-muted-foreground\">{symbol}</span>\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div className=\"text-right text-xs text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t<p>\n\t\t\t\t\t\t\t\t\t\t{viewHovered\n\t\t\t\t\t\t\t\t\t\t\t? 'Selected time'\n\t\t\t\t\t\t\t\t\t\t\t: viewTide.next\n\t\t\t\t\t\t\t\t\t\t\t\t? viewTide.next.type === 'H'\n\t\t\t\t\t\t\t\t\t\t\t\t\t? 'Rising toward high tide'\n\t\t\t\t\t\t\t\t\t\t\t\t\t: 'Falling toward low tide'\n\t\t\t\t\t\t\t\t\t\t\t\t: 'Tide outlook'}\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<p className=\"mt-1 tabular-nums\" data-slot=\"tide-time\">\n\t\t\t\t\t\t\t\t\t\t<span className=\"sr-only\">\n\t\t\t\t\t\t\t\t\t\t\t{formatTime(viewDisplayedTime, displayTimeZone)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\">\n\t\t\t\t\t\t\t\t\t\t\t{viewTimeParts.map((part, index) =>\n\t\t\t\t\t\t\t\t\t\t\t\tpart.type === 'hour' || part.type === 'minute' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<NumberFlow\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tkey={`${part.type}-${index}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={Number(part.value)}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tformat={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tminimumIntegerDigits: part.type === 'minute' ? 2 : 1,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tuseGrouping: false\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\tpart.value.replace(/[\\u00a0\\u202f]/g, ' ')\n\t\t\t\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\tdata-slot=\"tide-chart-layout\"\n\t\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\t\tsize === 'sm'\n\t\t\t\t\t\t\t\t\t\t? 'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3'\n\t\t\t\t\t\t\t\t\t\t: 'contents'\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{size === 'sm' ? neighbors : null}\n\t\t\t\t\t\t\t\t<div className={size === 'sm' ? 'grid min-w-0 gap-1' : 'contents'}>\n\t\t\t\t\t\t\t\t\t{viewChartData.length > 1 ? (\n\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t<ChartContainer\n\t\t\t\t\t\t\t\t\t\t\t\tconfig={chartConfig}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={`aspect-auto w-full ${size === 'sm' ? 'h-16' : size === 'lg' ? 'h-36' : 'h-28'}`}\n\t\t\t\t\t\t\t\t\t\t\t\trole=\"img\"\n\t\t\t\t\t\t\t\t\t\t\t\taria-label=\"Tide prediction curve with predicted water level marker\"\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<AreaChart\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata={viewChartData}\n\t\t\t\t\t\t\t\t\t\t\t\t\theight={chartHeight}\n\t\t\t\t\t\t\t\t\t\t\t\t\tmargin={{ top: 8, right: 8, bottom: 4, left: 8 }}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonMouseMove={(state) => {\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst index = Number(state.activeTooltipIndex);\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tconst point = Number.isInteger(index)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? viewChartData[index]\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined;\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tupdateHover(point ?? null);\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonMouseLeave={() => updateHover(null)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<CartesianGrid\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tvertical={false}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstroke=\"var(--border)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"3 4\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<XAxis\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataKey=\"time\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"number\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdomain={[\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tviewChartData[0].time,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tviewChartData[viewChartData.length - 1].time\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t]}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\thide\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<YAxis hide domain={viewDomain} />\n\t\t\t\t\t\t\t\t\t\t\t\t\t<ChartTooltip\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tcontent={\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<TideTooltipContent\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tindicator=\"line\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tonHover={updateHover}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdisplayTimeZone={displayTimeZone}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tunit={unit}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t<Area\n\t\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"monotone\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdataKey=\"height\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tname=\"Tide level\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstroke=\"var(--chart-1)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tfill=\"var(--chart-1)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tfillOpacity={0.12}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tisAnimationActive={false}\n\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{viewIndicatorLevel !== null &&\n\t\t\t\t\t\t\t\t\t\t\t\t\tviewIndicatorTime >= viewChartData[0].time &&\n\t\t\t\t\t\t\t\t\t\t\t\t\tviewIndicatorTime <= viewChartData[viewChartData.length - 1].time ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<ReferenceLine\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tx={viewIndicatorTime}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstroke=\"var(--muted-foreground)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeDasharray=\"3 4\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<ReferenceDot\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tx={viewIndicatorTime}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ty={viewIndicatorLevel}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tr={4.5}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tfill=\"var(--chart-1)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstroke=\"var(--card)\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tstrokeWidth={2}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tdata-slot=\"current-tide-marker\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t</AreaChart>\n\t\t\t\t\t\t\t\t\t\t\t</ChartContainer>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={`flex justify-between gap-1 text-[10px] text-muted-foreground ${size === 'sm' ? '' : '-mt-2'}`}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatTime(viewChartData[0].time, displayTimeZone)}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t{size !== 'sm' ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t<span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tMLLW · {viewTide.points.length ? 'predicted curve' : 'extrema only'}\n\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{formatTime(viewChartData.at(-1)!.time, displayTimeZone)}</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t{size !== 'sm' ? neighbors : null}\n\t\t\t\t\t\t\t{type !== 'simple' ? (\n\t\t\t\t\t\t\t\t<div className=\"divide-y border-t\">\n\t\t\t\t\t\t\t\t\t{(viewTide.events as TidePrediction[])\n\t\t\t\t\t\t\t\t\t\t.filter((event: TidePrediction) => tideTimestamp(event.time) > viewNow)\n\t\t\t\t\t\t\t\t\t\t.slice(0, type === 'detailed' ? 6 : density === 'compact' ? 2 : 4)\n\t\t\t\t\t\t\t\t\t\t.map((event: TidePrediction) => (\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tkey={`${event.time}-${event.type}`}\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={`grid grid-cols-[auto_1fr_auto] items-center gap-3 text-xs ${density === 'compact' ? 'py-2' : 'py-3'}`}\n\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t<span>{event.type === 'H' ? 'High tide' : 'Low tide'}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"ml-auto text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{formatDateTime(event.time, displayTimeZone)}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"tabular-nums\">\n\t\t\t\t\t\t\t\t\t\t\t\t\t{height(Number(event.height))} {symbol}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t</>\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<p role=\"status\" className=\"py-8 text-center text-sm text-muted-foreground\">\n\t\t\t\t\t\t\tNo tide predictions available.\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t\t{!day && sourceLabel ? (\n\t\t\t\t\t\t<p className=\"text-[10px] text-muted-foreground\">{sourceLabel}</p>\n\t\t\t\t\t) : null}\n\t\t\t\t</CardContent>\n\t\t\t</>\n\t\t);\n\t}\n\tconst upcomingSummary = (availableHeight: number) => {\n\t\tconst events = tide.events\n\t\t\t.filter((event) => tideTimestamp(event.time) >= now)\n\t\t\t.slice(0, size === 'lg' ? 12 : Math.max(1, Math.floor(availableHeight / 36)));\n\t\treturn (\n\t\t\t<div\n\t\t\t\tclassName=\"grid h-full w-full overflow-y-auto\"\n\t\t\t\tstyle={{ gridAutoRows: `minmax(${size === 'lg' ? 40 : 36}px, 1fr)` }}\n\t\t\t\tdata-slot=\"upcoming-tides\"\n\t\t\t>\n\t\t\t\t{events.length ? (\n\t\t\t\t\tevents.map((event) => (\n\t\t\t\t\t\t<div\n\t\t\t\t\t\t\tclassName={`grid min-w-0 grid-cols-[auto_1fr_auto] items-center gap-3 border-b last:border-0 ${size === 'lg' ? 'text-sm' : 'text-xs'}`}\n\t\t\t\t\t\t\tkey={`${event.time}-${event.type}`}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"font-medium\">{event.type === 'H' ? 'High tide' : 'Low tide'}</span>\n\t\t\t\t\t\t\t<time\n\t\t\t\t\t\t\t\tdateTime={new Date(tideTimestamp(event.time)).toISOString()}\n\t\t\t\t\t\t\t\tclassName=\"text-right text-muted-foreground\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t{formatDateTime(event.time, displayTimeZone)}\n\t\t\t\t\t\t\t</time>\n\t\t\t\t\t\t\t<span className=\"text-right tabular-nums\">\n\t\t\t\t\t\t\t\t{height(Number(event.height))} {symbol}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))\n\t\t\t\t) : (\n\t\t\t\t\t<p className=\"py-4 text-sm text-muted-foreground\">\n\t\t\t\t\t\tNo upcoming tide predictions available.\n\t\t\t\t\t</p>\n\t\t\t\t)}\n\t\t\t</div>\n\t\t);\n\t};\n\n\treturn (\n\t\t<Card\n\t\t\tstyle={{ containerType: 'inline-size' }}\n\t\t\tdata-density={density}\n\t\t\tdata-card-size={size}\n\t\t\tdata-size={size === 'sm' ? 'sm' : 'default'}\n\t\t\tclassName={`relative isolate min-w-0 overflow-hidden ${className}`}\n\t\t>\n\t\t\t<ForecastScreens\n\t\t\t\tinteractive={interactive}\n\t\t\t\tdays={days}\n\t\t\t\ttitle=\"Tide\"\n\t\t\t\tdensity={density}\n\t\t\t\tsourceLabel={sourceLabel}\n\t\t\t\ticonType={iconType}\n\t\t\t\tshowWeek\n\t\t\t\tactionLabel=\"Upcoming tides\"\n\t\t\t\tsummary={upcomingSummary}\n\t\t\t\tchildren={(openDay, action) => cardView(undefined, action, openDay)}\n\t\t\t\tdetail={(day, action) => cardView(day, action, () => {})}\n\t\t\t/>\n\t\t</Card>\n\t);\n}\n\nexport default TideForecast;\n"
    },
    {
      "path": "packages/react/src/components/wxcn/forecast-screens.tsx",
      "type": "registry:component",
      "target": "@components/wxcn/forecast-screens.tsx",
      "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';\nimport { CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Button } from '@/components/ui/button';\nimport { ForecastIcon, type IconSet } from './forecast-icons';\nimport type { ForecastDay } from '@/lib/wxcn/forecast-days.js';\nexport type ForecastAction = (onSurface: boolean) => ReactNode;\nexport type OpenForecastDay = (time: string, trigger: HTMLElement) => void;\nexport interface ForecastScreensProps {\n\tinteractive: boolean;\n\tdays: ForecastDay[];\n\ttitle: string;\n\tdensity: string;\n\tsourceLabel: string;\n\tsummary?: (availableHeight: number) => ReactNode;\n\tdaySummary?: (day: ForecastDay) => ReactNode;\n\tactionLabel?: string;\n\tsummaryTitle?: string;\n\tshowWeek?: boolean;\n\ticonType?: IconSet;\n\tflush?: boolean;\n\tdetail: (day: ForecastDay, action: ForecastAction) => ReactNode;\n\tchildren: (\n\t\topenDay: OpenForecastDay,\n\t\taction: ForecastAction,\n\t\toverviewVisible: boolean\n\t) => ReactNode;\n}\nexport function ForecastScreens({\n\tinteractive,\n\tdays,\n\ttitle,\n\tsummary,\n\tdaySummary,\n\tactionLabel = 'View week',\n\tsummaryTitle = 'Upcoming tides',\n\tshowWeek = true,\n\ticonType,\n\tflush = false,\n\tdetail,\n\tchildren\n}: ForecastScreensProps) {\n\tconst [screen, setScreen] = useState<'overview' | 'week' | 'day'>('overview');\n\tconst [selectedKey, setSelectedKey] = useState('');\n\tconst [availableHeight, setAvailableHeight] = useState(0);\n\tconst surface = useRef<HTMLDivElement>(null);\n\tconst table = useRef<HTMLDivElement>(null);\n\tconst host = useRef<HTMLElement | null>(null);\n\tconst fromWeek = useRef(false);\n\tconst originIndex = useRef(0);\n\tconst originTrigger = useRef<HTMLElement | null>(null);\n\tconst focusTarget = useRef<'surface' | 'origin' | 'day' | null>(null);\n\tconst selected = days.find((day) => day.key === selectedKey);\n\tconst active = interactive && (screen !== 'day' || selected) ? screen : 'overview';\n\tuseEffect(() => {\n\t\tif (active !== screen) setScreen('overview');\n\t}, [active, screen]);\n\tuseLayoutEffect(() => {\n\t\tif (active !== 'week' || !table.current) return;\n\t\tconst node = table.current;\n\t\tconst update = () => setAvailableHeight(node.clientHeight);\n\t\tconst observer = new ResizeObserver(update);\n\t\tobserver.observe(node);\n\t\tupdate();\n\t\treturn () => observer.disconnect();\n\t}, [active]);\n\tuseLayoutEffect(() => {\n\t\tconst target = focusTarget.current;\n\t\tif (!target) return;\n\t\tif (target === 'surface') {\n\t\t\tsurface.current?.focus({ preventScroll: true });\n\t\t\tfocusTarget.current = null;\n\t\t\treturn;\n\t\t}\n\t\tif ((target === 'day' && active !== 'week') || (target === 'origin' && active !== 'overview'))\n\t\t\treturn;\n\t\t// Wait for the retained overview or resized detail surface to become visible.\n\t\tlet frame = requestAnimationFrame(() => {\n\t\t\tframe = requestAnimationFrame(() => {\n\t\t\t\tconst trigger =\n\t\t\t\t\ttarget === 'day'\n\t\t\t\t\t\t? host.current?.querySelector<HTMLButtonElement>(`[data-forecast-day=\"${selectedKey}\"]`)\n\t\t\t\t\t\t: originTrigger.current?.isConnected\n\t\t\t\t\t\t\t? originTrigger.current\n\t\t\t\t\t\t\t: host.current?.querySelectorAll<HTMLButtonElement>('button')[originIndex.current];\n\t\t\t\ttrigger?.focus({ preventScroll: true });\n\t\t\t\tif (document.activeElement === trigger) focusTarget.current = null;\n\t\t\t});\n\t\t});\n\t\treturn () => cancelAnimationFrame(frame);\n\t}, [active, selectedKey, availableHeight]);\n\n\tfunction open(next: 'week' | 'day', trigger: HTMLElement, key = '') {\n\t\tif (active === 'overview') {\n\t\t\thost.current = trigger.closest<HTMLElement>('[data-slot=card]');\n\t\t\toriginTrigger.current = trigger;\n\t\t\toriginIndex.current = host.current\n\t\t\t\t? [...host.current.querySelectorAll('button')].indexOf(trigger as HTMLButtonElement)\n\t\t\t\t: 0;\n\t\t}\n\t\tfromWeek.current = active === 'week';\n\t\tsetSelectedKey(key);\n\t\tfocusTarget.current = 'surface';\n\t\tsetScreen(next);\n\t}\n\tconst openDay: OpenForecastDay = (time, trigger) => {\n\t\tconst day = days.find((day) => day.entries.some((entry) => entry.time === Date.parse(time)));\n\t\tif (day) open('day', trigger, day.key);\n\t};\n\tfunction back() {\n\t\tif (active === 'day' && fromWeek.current) {\n\t\t\tfocusTarget.current = 'day';\n\t\t\tsetScreen('week');\n\t\t} else {\n\t\t\tfocusTarget.current = 'origin';\n\t\t\tsetScreen('overview');\n\t\t}\n\t}\n\tconst weekAction: ForecastAction = (onSurface) =>\n\t\tinteractive && showWeek ? (\n\t\t\t<CardAction>\n\t\t\t\t<Button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\tsize=\"sm\"\n\t\t\t\t\tclassName={`h-6 border border-transparent px-1.5 text-[10px] font-medium ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:text-foreground'}`}\n\t\t\t\t\taria-label={summary ? actionLabel : `View ${title.toLowerCase()} week`}\n\t\t\t\t\tonClick={(event) => open('week', event.currentTarget)}\n\t\t\t\t>\n\t\t\t\t\t{actionLabel}\n\t\t\t\t</Button>\n\t\t\t</CardAction>\n\t\t) : null;\n\tconst backAction: ForecastAction = (onSurface) => (\n\t\t<CardAction>\n\t\t\t<Button\n\t\t\t\ttype=\"button\"\n\t\t\t\tvariant=\"ghost\"\n\t\t\t\tsize=\"sm\"\n\t\t\t\tclassName={`h-6 gap-1 border border-transparent px-1.5 text-[10px] ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground'}`}\n\t\t\t\tonClick={back}\n\t\t\t>\n\t\t\t\t<ForecastIcon name=\"arrowDown\" iconSet={iconType} className=\"size-3 rotate-90\" />\n\t\t\t\tBack\n\t\t\t</Button>\n\t\t</CardAction>\n\t);\n\treturn (\n\t\t<>\n\t\t\t<div\n\t\t\t\tclassName=\"contents\"\n\t\t\t\tstyle={{ visibility: active === 'overview' ? 'visible' : 'hidden' }}\n\t\t\t\tinert={active !== 'overview'}\n\t\t\t\taria-hidden={active !== 'overview'}\n\t\t\t>\n\t\t\t\t{children(openDay, weekAction, active === 'overview')}\n\t\t\t</div>\n\t\t\t{active !== 'overview' && (\n\t\t\t\t<div\n\t\t\t\t\tref={surface}\n\t\t\t\t\ttabIndex={-1}\n\t\t\t\t\trole=\"group\"\n\t\t\t\t\taria-label={\n\t\t\t\t\t\tactive === 'day'\n\t\t\t\t\t\t\t? `${selected?.label} ${title.toLowerCase()} forecast`\n\t\t\t\t\t\t\t: summary\n\t\t\t\t\t\t\t\t? summaryTitle\n\t\t\t\t\t\t\t\t: `${title} • Week`\n\t\t\t\t\t}\n\t\t\t\t\tdata-slot=\"forecast-screen\"\n\t\t\t\t\tonKeyDown={(event) => {\n\t\t\t\t\t\tif (event.key === 'Escape') {\n\t\t\t\t\t\t\tevent.preventDefault();\n\t\t\t\t\t\t\tback();\n\t\t\t\t\t\t}\n\t\t\t\t\t}}\n\t\t\t\t\tclassName={`absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden rounded-[inherit] bg-card text-card-foreground outline-none ${active === 'day' && flush ? 'gap-0' : active === 'week' ? 'gap-2 py-3' : 'gap-[var(--card-spacing,var(--wxcn-spacing,1.5rem))] py-[var(--card-spacing,var(--wxcn-spacing,1.5rem))]'}`}\n\t\t\t\t>\n\t\t\t\t\t{active === 'day' && selected ? (\n\t\t\t\t\t\tdetail(selected, backAction)\n\t\t\t\t\t) : (\n\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t<CardHeader className=\"shrink-0\">\n\t\t\t\t\t\t\t\t<CardTitle className=\"truncate\">\n\t\t\t\t\t\t\t\t\t{summary ? summaryTitle : `${title} • Week`}\n\t\t\t\t\t\t\t\t</CardTitle>\n\t\t\t\t\t\t\t\t{backAction(false)}\n\t\t\t\t\t\t\t</CardHeader>\n\t\t\t\t\t\t\t<CardContent className=\"min-h-0 min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t<div ref={table} className=\"h-full min-h-0\" data-slot=\"forecast-week-table\">\n\t\t\t\t\t\t\t\t\t{summary ? (\n\t\t\t\t\t\t\t\t\t\tsummary(availableHeight)\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tclassName={`grid h-full content-start gap-x-3 overflow-y-auto ${!daySummary && availableHeight < days.length * 28 ? 'grid-cols-2' : 'grid-cols-1'}`}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{days.map((day) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={day.key}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\t\t\t\tdata-forecast-day={day.key}\n\t\t\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\theight: daySummary\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? Math.max(32, availableHeight / days.length)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: Math.min(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t28,\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tavailableHeight /\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t(!daySummary && availableHeight < days.length * 28\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? Math.ceil(days.length / 2)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: days.length)\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tfontSize: daySummary\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t? `clamp(12px, min(4.5cqw, ${availableHeight / days.length / 3}px), 20px)`\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t: undefined\n\t\t\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"flex min-h-0 min-w-0 items-center justify-between gap-2 border-b text-left text-[11px] hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring\"\n\t\t\t\t\t\t\t\t\t\t\t\t\taria-label={`View details for ${day.label}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tonClick={(event) => open('day', event.currentTarget, day.key)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{daySummary ? (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tdaySummary(day)\n\t\t\t\t\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t<>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span className=\"shrink-0 font-medium\">{day.label}</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"truncate text-muted-foreground\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={day.entries.map((entry) => entry.summary).join(' · ')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t{day.entries.map((entry) => entry.summary).join(' · ')}\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t</>\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</CardContent>\n\t\t\t\t\t\t</>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</>\n\t);\n}\n"
    },
    {
      "path": "packages/react/src/icons/forecast-icons.tsx",
      "type": "registry:component",
      "target": "@components/wxcn/forecast-icons.tsx",
      "content": "import { IconPlaceholder } from '@/components/icon-placeholder';\nexport type IconName = 'arrowUp' | 'arrowDown' | 'weather' | 'sun' | 'moon' | 'tide' | 'wind' | 'rain' | 'snow';\nexport type IconSet = 'lucide' | 'tabler' | 'phosphor' | 'hugeicons' | 'remixicon';\nexport type ForecastIconProps = { name: IconName; iconSet?: IconSet; className?: string };\nexport function ForecastIcon({ name, className = 'size-5' }: ForecastIconProps) {\n switch (name) {\n case 'arrowUp': return <IconPlaceholder lucide=\"ArrowUpIcon\" tabler=\"IconArrowUp\" phosphor=\"ArrowUpIcon\" hugeicons=\"ArrowUp02Icon\" remixicon=\"RiArrowUpLine\" className={className} aria-hidden=\"true\" />;\ncase 'arrowDown': return <IconPlaceholder lucide=\"ArrowDownIcon\" tabler=\"IconArrowDown\" phosphor=\"ArrowDownIcon\" hugeicons=\"ArrowDown02Icon\" remixicon=\"RiArrowDownLine\" className={className} aria-hidden=\"true\" />;\ncase 'weather': return <IconPlaceholder lucide=\"CloudSunIcon\" tabler=\"IconCloud\" phosphor=\"CloudSunIcon\" hugeicons=\"SunCloud02Icon\" remixicon=\"RiCloudy2Line\" className={className} aria-hidden=\"true\" />;\ncase 'sun': return <IconPlaceholder lucide=\"SunIcon\" tabler=\"IconSun\" phosphor=\"SunIcon\" hugeicons=\"Sun03Icon\" remixicon=\"RiSunLine\" className={className} aria-hidden=\"true\" />;\ncase 'moon': return <IconPlaceholder lucide=\"MoonIcon\" tabler=\"IconMoon\" phosphor=\"MoonIcon\" hugeicons=\"Moon02Icon\" remixicon=\"RiMoonLine\" className={className} aria-hidden=\"true\" />;\ncase 'tide': return <IconPlaceholder lucide=\"WavesIcon\" tabler=\"IconWavesElectricity\" phosphor=\"WavesIcon\" hugeicons=\"WaterfallUp01Icon\" remixicon=\"RiWaterFlashLine\" className={className} aria-hidden=\"true\" />;\ncase 'wind': return <IconPlaceholder lucide=\"WindIcon\" tabler=\"IconWind\" phosphor=\"WindIcon\" hugeicons=\"WindPower02Icon\" remixicon=\"RiWindyLine\" className={className} aria-hidden=\"true\" />;\ncase 'rain': return <IconPlaceholder lucide=\"CloudRainIcon\" tabler=\"IconCloudRain\" phosphor=\"CloudRainIcon\" hugeicons=\"CloudRainIcon\" remixicon=\"RiRainyLine\" className={className} aria-hidden=\"true\" />;\ncase 'snow': return <IconPlaceholder lucide=\"SnowflakeIcon\" tabler=\"IconSnowflake\" phosphor=\"SnowflakeIcon\" hugeicons=\"SnowIcon\" remixicon=\"RiSnowyLine\" className={className} aria-hidden=\"true\" />;\n }\n}\n"
    },
    {
      "path": "packages/core/src/types.ts",
      "type": "registry:lib",
      "target": "@lib/wxcn/types.ts",
      "content": "export type CardSize = 'sm' | 'default' | 'lg';\nexport type CardDensity = 'compact' | 'comfortable';\nexport type ForecastType = 'summary' | 'detailed' | 'simple';\nexport type IconSet = 'hugeicons' | 'phosphor-svelte' | 'lucide' | 'tabler' | 'remix';\nexport type WeatherUnit = 'fahrenheit' | 'celsius';\nexport type TideUnit = 'ft' | 'meter';\n\nexport type LocationInput = {\n\tlabel?: string;\n\tlatitude: number;\n\tlongitude: number;\n\tstation?: string;\n\ttimeZone?: string;\n};\n\nexport type WeatherPeriod = {\n\tname: string;\n\tstartTime: string;\n\tendTime?: string;\n\ttemperature: number;\n\ttemperatureUnit: string;\n\twindSpeed: string;\n\twindDirection: string;\n\tshortForecast: string;\n\tdetailedForecast: string;\n\tisDaytime: boolean;\n};\n\nexport type TidePrediction = {\n\ttime: string;\n\theight: string;\n\ttype: 'H' | 'L';\n};\n\nexport type MoonForecast = {\n\tdate: string;\n\tphaseName: string;\n\t/** Astronomical phase cycle: 0 new, 0.25 first quarter, 0.5 full. */\n\tphase?: number;\n\tillumination: number;\n\tage: number;\n\tnextFullMoon: string;\n\tnextNewMoon: string;\n};\n\n/** Feet above MLLW. Prefer ISO 8601 timestamps with an explicit offset. */\nexport type TidePoint = { time: string; height: string };\nexport type TideReading = TidePoint;\n\n/** Current observed conditions; daily extrema use temperatureUnit and the location's calendar day. */\nexport type CurrentWeather = WeatherPeriod & {\n\tobservedAt: string;\n\thighToday?: number;\n\tlowToday?: number;\n};\n\nexport type WeatherBackground = 'none' | 'realistic' | 'dithered' | 'gradient';\n"
    },
    {
      "path": "packages/core/src/forecast-days.ts",
      "type": "registry:lib",
      "target": "@lib/wxcn/forecast-days.ts",
      "content": "export type ForecastEntry = { time: number; label: string; summary: string; details: string };\nexport type ForecastDay = { key: string; label: string; entries: ForecastEntry[] };\n\n/** Group supplied periods in the location's calendar, without inventing missing days. */\nexport function forecastDays(entries: ForecastEntry[], timeZone: string): ForecastDay[] {\n\tconst key = new Intl.DateTimeFormat('en-CA', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit'\n\t});\n\tconst label = new Intl.DateTimeFormat('en-US', {\n\t\ttimeZone,\n\t\tweekday: 'short',\n\t\tmonth: 'short',\n\t\tday: 'numeric'\n\t});\n\tconst days = new Map<string, ForecastDay>();\n\tfor (const entry of entries\n\t\t.filter((e) => Number.isFinite(e.time))\n\t\t.toSorted((a, b) => a.time - b.time)) {\n\t\tconst id = key.format(entry.time);\n\t\tif (!days.has(id)) days.set(id, { key: id, label: label.format(entry.time), entries: [] });\n\t\tdays.get(id)!.entries.push(entry);\n\t}\n\treturn [...days.values()].slice(0, 7);\n}\n\n/** Resolve noon on a forecast calendar date in the location's time zone, including DST. */\nexport function forecastDayNoon(key: string, timeZone: string): number {\n\tconst noon = Date.parse(`${key}T12:00:00Z`);\n\tconst format = new Intl.DateTimeFormat('en-US', {\n\t\ttimeZone,\n\t\tyear: 'numeric',\n\t\tmonth: '2-digit',\n\t\tday: '2-digit',\n\t\thour: '2-digit',\n\t\tminute: '2-digit',\n\t\tsecond: '2-digit',\n\t\thourCycle: 'h23'\n\t});\n\tlet instant = noon;\n\tfor (let attempt = 0; attempt < 3; attempt++) {\n\t\tconst parts = Object.fromEntries(\n\t\t\tformat.formatToParts(instant).map(({ type, value }) => [type, value])\n\t\t);\n\t\tconst wallTime = Date.UTC(\n\t\t\tNumber(parts.year),\n\t\t\tNumber(parts.month) - 1,\n\t\t\tNumber(parts.day),\n\t\t\tNumber(parts.hour),\n\t\t\tNumber(parts.minute),\n\t\t\tNumber(parts.second)\n\t\t);\n\t\tconst correction = noon - wallTime;\n\t\tinstant += correction;\n\t\tif (!correction) break;\n\t}\n\treturn instant;\n}\n"
    },
    {
      "path": "packages/core/src/tides.ts",
      "type": "registry:lib",
      "target": "@lib/wxcn/tides.ts",
      "content": "import type { LocationInput, TidePrediction } from './types.js';\n\ntype NoaaPrediction = {\n\tt: string;\n\tv: string;\n\ttype: 'H' | 'L';\n};\n\ntype NoaaResponse = {\n\tpredictions: NoaaPrediction[];\n};\n\nexport async function fetchTidePredictions(location: LocationInput, date = new Date()) {\n\tif (!location.station) {\n\t\tthrow new Error('NOAA tide predictions require a CO-OPS station id.');\n\t}\n\n\tconst begin = date.toISOString().slice(0, 10).replaceAll('-', '');\n\tconst url = new URL('https://api.tidesandcurrents.noaa.gov/api/prod/datagetter');\n\turl.search = new URLSearchParams({\n\t\tproduct: 'predictions',\n\t\tapplication: 'wxcn',\n\t\tbegin_date: begin,\n\t\trange: '168',\n\t\tdatum: 'MLLW',\n\t\tstation: location.station,\n\t\ttime_zone: 'gmt',\n\t\tunits: 'english',\n\t\tinterval: 'hilo',\n\t\tformat: 'json'\n\t}).toString();\n\n\tconst response = await fetch(url);\n\tif (!response.ok) {\n\t\tthrow new Error(`NOAA tide lookup failed with ${response.status}`);\n\t}\n\n\tconst data = (await response.json()) as NoaaResponse;\n\treturn data.predictions.map((prediction) => ({\n\t\ttime: prediction.t.replace(' ', 'T') + 'Z',\n\t\theight: prediction.v,\n\t\ttype: prediction.type\n\t})) satisfies TidePrediction[];\n}\n\nexport const sampleTides: TidePrediction[] = [\n\t{ time: '2026-09-06T02:12:00-05:00', height: '1.8', type: 'H' },\n\t{ time: '2026-09-06T08:28:00-05:00', height: '0.4', type: 'L' },\n\t{ time: '2026-09-06T14:49:00-05:00', height: '1.5', type: 'H' },\n\t{ time: '2026-09-06T21:07:00-05:00', height: '0.3', type: 'L' },\n\t{ time: '2026-09-07T02:49:00-05:00', height: '1.8', type: 'H' },\n\t{ time: '2026-09-07T09:05:00-05:00', height: '0.4', type: 'L' },\n\t{ time: '2026-09-07T15:26:00-05:00', height: '1.5', type: 'H' },\n\t{ time: '2026-09-07T21:44:00-05:00', height: '0.3', type: 'L' },\n\t{ time: '2026-09-08T03:26:00-05:00', height: '1.8', type: 'H' },\n\t{ time: '2026-09-08T09:42:00-05:00', height: '0.4', type: 'L' },\n\t{ time: '2026-09-08T16:03:00-05:00', height: '1.5', type: 'H' },\n\t{ time: '2026-09-08T22:21:00-05:00', height: '0.3', type: 'L' },\n\t{ time: '2026-09-09T04:03:00-05:00', height: '1.8', type: 'H' },\n\t{ time: '2026-09-09T10:19:00-05:00', height: '0.4', type: 'L' },\n\t{ time: '2026-09-09T16:40:00-05:00', height: '1.5', type: 'H' },\n\t{ time: '2026-09-09T22:58:00-05:00', height: '0.3', type: 'L' }\n];\n\nexport const sampleTideTime = Date.parse('2026-09-06T16:30:00Z');\n// Illustrative six-minute samples, used only by the labeled coastal example.\nexport const sampleTideSeries = Array.from({ length: 241 }, (_, i) => {\n\tconst time = Date.parse('2026-09-06T05:00:00Z') + i * 360000;\n\treturn {\n\t\ttime: new Date(time).toISOString(),\n\t\theight: (\n\t\t\t0.95 +\n\t\t\t0.65 *\n\t\t\t\tMath.cos(((time - Date.parse('2026-09-06T07:12:00Z')) / (12.6 * 3600000)) * 2 * Math.PI)\n\t\t).toFixed(3)\n\t};\n});\n"
    },
    {
      "path": "packages/core/src/tide-state.ts",
      "type": "registry:lib",
      "target": "@lib/wxcn/tide-state.ts",
      "content": "import type { TidePrediction, TideReading, TidePoint } from './types.js';\n\n// Offset-free legacy NOAA values are station wall times; all new API data uses UTC.\nexport function tideTimestamp(time: string) {\n\treturn Date.parse(/[zZ]|[+-]\\d\\d:\\d\\d$/.test(time) ? time : time.replace(' ', 'T') + 'Z');\n}\nexport function tideState(\n\tpredictions: TidePrediction[],\n\tseries: TidePoint[],\n\treading: TideReading | null,\n\tnow: number\n) {\n\tconst events = predictions\n\t\t.filter((p) => Number.isFinite(tideTimestamp(p.time)) && Number.isFinite(Number(p.height)))\n\t\t.toSorted((a, b) => tideTimestamp(a.time) - tideTimestamp(b.time));\n\tconst previous = events.filter((p) => tideTimestamp(p.time) <= now).at(-1);\n\tconst next = events.find((p) => tideTimestamp(p.time) > now);\n\tconst points = series\n\t\t.map((p) => ({ time: tideTimestamp(p.time), height: Number(p.height) }))\n\t\t.filter((p) => Number.isFinite(p.time) && Number.isFinite(p.height))\n\t\t.toSorted((a, b) => a.time - b.time);\n\tconst before = points.filter((p) => p.time <= now).at(-1),\n\t\tafter = points.find((p) => p.time >= now);\n\tconst predicted =\n\t\tbefore && after\n\t\t\t? before.height +\n\t\t\t\t(after.height - before.height) *\n\t\t\t\t\t(after.time === before.time ? 0 : (now - before.time) / (after.time - before.time))\n\t\t\t: null;\n\tconst observed =\n\t\treading &&\n\t\treading.height.trim() &&\n\t\tNumber.isFinite(Number(reading.height)) &&\n\t\tnow - tideTimestamp(reading.time) >= 0 &&\n\t\tnow - tideTimestamp(reading.time) <= 30 * 60000\n\t\t\t? reading\n\t\t\t: null;\n\treturn {\n\t\tevents,\n\t\tprevious,\n\t\tnext,\n\t\tpoints,\n\t\tpredicted,\n\t\tobserved,\n\t\tlevel: observed ? Number(observed.height) : predicted\n\t};\n}\n"
    }
  ]
}
