From 1f30f507a22e138375f943e901df455622b7711d Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 14:53:27 +1100 Subject: [PATCH 1/6] Add API reference for TimeSeriesForecastChart --- .../stories/index.api.mdx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.api.mdx diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.api.mdx new file mode 100644 index 000000000000..0120b5b78ad4 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.api.mdx @@ -0,0 +1,110 @@ +import { Meta, Source } from '@storybook/addon-docs/blocks'; + + + +# Time Series Forecast Chart API Reference + +## TimeSeriesForecastChart + +Main chart component with responsive behavior by default. The component is generic over datum type `D`. + +**Props:** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `data` | `readonly D[]` | - | **Required.** Array of data points to display | +| `accessors` | `TimeSeriesForecastAccessors` | - | **Required.** Accessor functions to extract values from each datum | +| `forecastStart` | `Date` | - | **Required.** The date at which the forecast region begins | +| `height` | `number` | responsive | Chart height in pixels | +| `width` | `number` | responsive | Chart width in pixels | +| `margin` | `{ top?: number; right?: number; bottom?: number; left?: number }` | `{ top: 20, right: 20, bottom: 40, left: 50 }` | Chart margins | +| `yDomain` | `[number, number]` | auto-calculated | Fixed y-axis domain [min, max] | +| `xTickFormat` | `(d: Date) => string` | short month + day | Custom formatter for x-axis tick labels | +| `yTickFormat` | `(n: number) => string` | `formatNumberCompact` | Custom formatter for y-axis tick labels | +| `withTooltips` | `boolean` | `true` | Enable interactive tooltips on hover | +| `seriesKeys` | `SeriesKeys` | `{ historical: 'Historical', forecast: 'Forecast', band: 'Uncertainty' }` | Custom labels for series in legend and tooltip | +| `renderTooltip` | `(params: ForecastTooltipParams) => ReactNode` | - | Custom tooltip renderer | +| `className` | `string` | - | Additional CSS class name for the chart container | +| `chartId` | `string` | auto-generated | Unique identifier for the chart | +| `showLegend` | `boolean` | `false` | Display the chart legend | +| `legendPosition` | `'top' \| 'bottom'` | `'bottom'` | Position of the legend relative to the chart | +| `animation` | `boolean` | `false` | Enable entry animation on initial render. Creates a rising effect where paths scale up from the bottom. Automatically respects user's `prefers-reduced-motion` system setting | +| `gridVisibility` | `'x' \| 'y' \| 'xy' \| 'none'` | `'y'` | Control which grid lines are displayed | +| `gap` | `GapSize` | `'md'` | Gap between chart elements (SVG, legend). Uses WordPress design system tokens | +| `bandFillOpacity` | `number` | `0.2` | Opacity of the uncertainty band fill (0-1) | + +**Responsive wrapper additional props (default export):** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `maxWidth` | `number` | - | Maximum width constraint for responsive charts | +| `aspectRatio` | `number` | - | Height as a fraction of width (e.g., `0.5` = 50% height) | +| `resizeDebounceTime` | `number` | `300` | Debounce delay for resize events in milliseconds | + +## TimeSeriesForecastChartUnresponsive + +Non-responsive variant that requires explicit `width` and `height` props. Accepts the same props as `TimeSeriesForecastChart` without the responsive wrapper additions. + +## TimeSeriesForecastAccessors Type + +Accessor functions to extract values from generic datum type `D`. + +```typescript +type TimeSeriesForecastAccessors = { + x: Accessor; + y: Accessor; + yLower?: Accessor; + yUpper?: Accessor; +}; +``` + +- **`x`**: **Required.** Extract the x-axis date value from each datum +- **`y`**: **Required.** Extract the y-axis numeric value from each datum +- **`yLower`**: Extract the lower bound of the forecast uncertainty. The uncertainty band is only rendered for points where both `yLower` and `yUpper` return valid numbers +- **`yUpper`**: Extract the upper bound of the forecast uncertainty. The uncertainty band is only rendered for points where both `yLower` and `yUpper` return valid numbers + +## SeriesKeys Type + +Custom labels for legend and tooltip display. + +```typescript +type SeriesKeys = { + historical?: string; + forecast?: string; + band?: string; +}; +``` + +- **`historical`**: Label for the historical series. Default: `'Historical'` +- **`forecast`**: Label for the forecast series. Default: `'Forecast'` +- **`band`**: Label for the uncertainty band. Default: `'Uncertainty'` + +## ForecastTooltipParams Type + +Parameters passed to the custom `renderTooltip` function. + +```typescript +interface ForecastTooltipParams { + nearest: D; + x: Date; + y: number; + yLower?: number; + yUpper?: number; + isForecast: boolean; +} +``` + +- **`nearest`**: The original datum nearest to the cursor +- **`x`**: The x-axis date value +- **`y`**: The y-axis numeric value +- **`yLower`**: The lower bound of the forecast uncertainty (if available) +- **`yUpper`**: The upper bound of the forecast uncertainty (if available) +- **`isForecast`**: Whether this point is in the forecast region + +## Accessor Type + +Generic accessor function type used by `TimeSeriesForecastAccessors`. + +```typescript +type Accessor = (d: D, index: number) => R; +``` From 67ca33168e33db16904ec72d82899c3ab29e3292 Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 14:53:31 +1100 Subject: [PATCH 2/6] Add Storybook documentation for TimeSeriesForecastChart --- .../stories/index.docs.mdx | 483 ++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.docs.mdx diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.docs.mdx new file mode 100644 index 000000000000..2431cc997672 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/stories/index.docs.mdx @@ -0,0 +1,483 @@ +import { Meta, Canvas, Story, Source } from '@storybook/addon-docs/blocks'; +import * as TimeSeriesForecastChartStories from './index.stories'; + + + +# Time Series Forecast Chart + +Displays historical data alongside forecasted values with optional uncertainty bands, connecting the two regions with a visual divider at the forecast start date. + + + +## Overview + +The Time Series Forecast Chart component visualizes time-series data split into historical and forecast regions. Historical data renders as a solid line, forecast data as a dashed line, and an optional shaded band shows the uncertainty range between upper and lower bounds. A vertical dashed divider marks the transition point between historical and forecast data. + +The component is generic over your data type — pass any shape of data along with accessor functions to extract the values the chart needs: + + d.date, + y: d => d.value, + yLower: d => d.lower, + yUpper: d => d.upper, + } } + forecastStart={ new Date('2024-02-19') } + />` } +/> + +## API Reference + +For detailed information about component props, types, and method signatures, see the [Time Series Forecast Chart API Reference](./?path=/docs/js-packages-charts-library-charts-time-series-forecast-chart-api-reference--docs). + +## Basic Usage + +### Simple Forecast Chart + +The simplest forecast chart requires `data`, `accessors`, and `forecastStart`: + + + + d.date, + y: d => d.value, + yLower: d => d.lower, + yUpper: d => d.upper, + } } + forecastStart={ new Date('2024-02-01') } + />` } +/> + +### Required Props + +- **`data`**: Array of data points of any shape (generic type `D`). The chart uses accessor functions to extract values from each datum. +- **`accessors`**: Object with `x` and `y` accessor functions (plus optional `yLower` and `yUpper` for uncertainty bounds). +- **`forecastStart`**: A `Date` marking where the forecast region begins. Points before this date are historical; points on or after are forecast. +- **`height`**: Chart height in pixels (required for the unresponsive variant; the default responsive wrapper calculates this automatically). + +### Optional Props + +**Layout & Dimensions:** +- **`width`**: Chart width in pixels (responsive by default) +- **`margin`**: Custom chart margins +- **`className`**: Additional CSS class name for the chart container +- **`chartId`**: Unique identifier for the chart (auto-generated if not provided) +- **`aspectRatio`**: Height as a fraction of width (e.g., `0.5` = 50% height). When omitted, fills parent container height +- **`maxWidth`**: Maximum width constraint for responsive charts +- **`resizeDebounceTime`**: Debounce delay for resize events in ms (default: `300`) +- **`gap`**: Gap between chart elements using WordPress design tokens (default: `'md'`) + +**Visual Styling:** +- **`yDomain`**: Fixed y-axis domain `[min, max]` (auto-calculated by default) +- **`xTickFormat`**: Custom formatter for x-axis tick labels +- **`yTickFormat`**: Custom formatter for y-axis tick labels +- **`gridVisibility`**: Control grid visibility (`'x'`, `'y'`, `'xy'`, or `'none'`) +- **`bandFillOpacity`**: Opacity of the uncertainty band fill (default: `0.2`) + +**Interactivity:** +- **`withTooltips`**: Enable interactive tooltips (`true` by default) +- **`renderTooltip`**: Custom tooltip render function + +**Legend:** +- **`showLegend`**: Display chart legend (`false` by default) +- **`legendPosition`**: Legend position (`'top'` or `'bottom'`, default: `'bottom'`) +- **`seriesKeys`**: Custom labels for series in legend and tooltip + +**Advanced:** +- **`animation`**: Enable entry animation (`false` by default) + +For detailed prop information and type definitions, see the [Time Series Forecast Chart API Reference](./?path=/docs/js-packages-charts-library-charts-time-series-forecast-chart-api-reference--docs). + +## Forecast Regions + +### Historical and Forecast Data + +The chart automatically splits data into historical and forecast regions based on the `forecastStart` date. Historical points render as a solid line, forecast points as a dashed line. For a seamless visual transition, include a shared data point at the `forecastStart` date with tight bounds (equal lower and upper values): + +` } +/> + +### Historical Only + +When `forecastStart` is after all data points, only the historical line is shown without a divider or uncertainty band: + + + +### Forecast Only + +When `forecastStart` is before all data points, only the forecast line and band are shown: + + + +## Uncertainty Band + +### How the Band Works + +The uncertainty band is a shaded area between the lower and upper bounds of forecast data points. It only renders for points where **both** `yLower` and `yUpper` accessors return valid numbers. Points with missing bounds still appear on the forecast line but are excluded from the band. + +### Partial Bounds + +When some forecast points are missing bounds, the band renders only for the points that have complete bounds: + + + +### Band Opacity + +Control the opacity of the uncertainty band fill with the `bandFillOpacity` prop (default: `0.2`): + +` } +/> + +## Generic Data Types + +The chart is generic over your datum type `D`. Pass any data shape and provide accessor functions to map your data fields: + + + + + data={ customData } + accessors={ { + x: d => new Date(d.timestamp), + y: d => d.measurement, + yLower: d => d.confidenceLow, + yUpper: d => d.confidenceHigh, + } } + forecastStart={ new Date('2024-02-01') } + />` } +/> + +## Interactive Features + +### Tooltips + +Tooltips are enabled by default and show the date, value, and (for forecast points with bounds) the uncertainty range. A "Forecast" badge appears for points in the forecast region: + +` } +/> + +### Custom Tooltip + +Provide a `renderTooltip` function to fully customize the tooltip content. The function receives the original datum, x/y values, optional bounds, and a flag indicating whether the point is in the forecast region: + + + + ( +
+
{ x.toLocaleDateString() }
+
Value: { y }
+ { isForecast && yLower !== undefined && yUpper !== undefined && ( +
Range: { yLower } - { yUpper }
+ ) } +
+ ) } + />` } +/> + +## Legends + +### Basic Legend + +Display a legend showing the historical (solid line) and forecast (dashed line) series: + + + +` } +/> + +### Legend Position + +Place the legend above or below the chart: + + + +` } +/> + +### Custom Series Labels + +Override the default series labels ("Historical", "Forecast", "Uncertainty") with the `seriesKeys` prop: + + + +` } +/> + +## Advanced Customization + +### Axis Formatting + +Customize axis tick labels with the `xTickFormat` and `yTickFormat` props: + + + + d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } + yTickFormat={ (n) => \`$\${n}\` } + />` } +/> + +### Y-Axis Domain + +Override the auto-calculated y-axis domain with a fixed range: + + + +` } +/> + +### Grid Visibility + +Control which grid lines are displayed: + + + + + + // X-axis grid only + + + // Both axes + + + // No grid + ` } +/> + +### Responsive Behavior + +By default, the chart fills its parent container's dimensions using the responsive wrapper. The parent must have an explicit height: + + + + + + // Use aspect ratio - height calculated from width +
+ +
+ + // Fixed dimensions + ` } +/> + +### Custom Margins + +Control chart layout with margin settings: + +` } +/> + +## Dynamic Data + +The chart works well with dynamically generated data. The `generateForecastData` helper included in the stories demonstrates realistic time series with configurable trend, volatility, and forecast length: + + + +## Error Handling + +The chart gracefully handles empty data by displaying a "No data available" message: + + + +## Accessibility + +### Keyboard Navigation + +- **Tab**: Focus the chart area +- **Arrow Keys**: Navigate between data points +- **Escape**: Close active tooltips + +### Screen Reader Support + +- Chart data points are navigable and announced with values +- Color information is supplemented with line style differences (solid vs dashed) + +### Focus Management + +- Clear visual focus indicators on interactive elements +- Logical tab order through chart elements + +## Browser Compatibility + +### Modern Browser Support + +Full functionality in all modern browsers supporting: +- SVG rendering +- CSS Grid and Flexbox +- ES6+ JavaScript features + +### Responsive Features + +- Built-in ResizeObserver support for automatic chart resizing +- Touch-friendly tooltip interactions on mobile devices + +## Performance Considerations + +### Built-in Optimizations + +- **Efficient rendering**: Built on `@visx/xychart` for optimized SVG rendering +- **Responsive behavior**: Uses `ResizeObserver` for efficient chart resizing without polling +- **Memoized computations**: Data transformation and domain calculations are memoized to avoid unnecessary recalculations + +## Theming Integration + +Time Series Forecast Charts integrate seamlessly with the chart theming system. The default theme has neutral colors and styling, and is automatically applied to all charts unless a custom theme is provided. A custom theme can be provided by wrapping your chart in `GlobalChartsProvider` and passing a custom theme object with the properties you want to override to the `theme` prop: + + + + ` } +/> + +## Animation + +The Time Series Forecast Chart component supports an optional entry animation that creates a smooth reveal effect when the chart first renders: + + + +` } +/> + +### Animation Behavior + +- **Opt-in**: Animation is disabled by default and must be explicitly enabled with the `animation` prop +- **Accessibility**: Automatically respects the user's `prefers-reduced-motion` system setting - animation will not play for users who prefer reduced motion +- **Effect**: Creates a rising effect where chart paths scale up from the bottom, revealing the data progressively +- **Duration**: 1000ms (1 second) with ease-out timing + +**Note**: The animation plays once when the chart initially renders and does not repeat. From ea6f1ee3d85870d9c4214ed32458731428fdadc2 Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 15:49:56 +1100 Subject: [PATCH 3/6] Add unit tests for useForecastData hook --- .../private/test/use-forecast-data.test.ts | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/private/test/use-forecast-data.test.ts diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/test/use-forecast-data.test.ts b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/test/use-forecast-data.test.ts new file mode 100644 index 000000000000..0494bc56dd17 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/private/test/use-forecast-data.test.ts @@ -0,0 +1,291 @@ +import { renderHook } from '@testing-library/react'; +import { useForecastData } from '../use-forecast-data'; +import type { TimeSeriesForecastAccessors } from '../../types'; + +type TestDatum = { + date: Date; + value: number; + lower?: number | null; + upper?: number | null; +}; + +const accessors: TimeSeriesForecastAccessors< TestDatum > = { + x: d => d.date, + y: d => d.value, + yLower: d => d.lower ?? null, + yUpper: d => d.upper ?? null, +}; + +const accessorsNoBounds: TimeSeriesForecastAccessors< TestDatum > = { + x: d => d.date, + y: d => d.value, +}; + +describe( 'useForecastData', () => { + test( 'returns safe defaults for empty data', () => { + const { result } = renderHook( () => + useForecastData( { + data: [], + accessors, + forecastStart: new Date( '2024-01-15' ), + } ) + ); + + expect( result.current.allPoints ).toEqual( [] ); + expect( result.current.historical ).toEqual( [] ); + expect( result.current.forecast ).toEqual( [] ); + expect( result.current.bandData ).toEqual( [] ); + expect( result.current.yDomain ).toEqual( [ 0, 100 ] ); + } ); + + test( 'sorts unsorted input by date', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-20' ), value: 30 }, + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-10' ), value: 20 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2024-02-01' ), + } ) + ); + + const dates = result.current.allPoints.map( p => p.date.getTime() ); + expect( dates ).toEqual( [ + new Date( '2024-01-05' ).getTime(), + new Date( '2024-01-10' ).getTime(), + new Date( '2024-01-20' ).getTime(), + ] ); + } ); + + test( 'transition point at forecastStart appears in both historical and forecast', () => { + const forecastStart = new Date( '2024-01-10' ); + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-10' ), value: 20 }, + { date: new Date( '2024-01-15' ), value: 30 }, + ]; + + const { result } = renderHook( () => + useForecastData( { data, accessors: accessorsNoBounds, forecastStart } ) + ); + + const historicalDates = result.current.historical.map( p => p.date.getTime() ); + const forecastDates = result.current.forecast.map( p => p.date.getTime() ); + + expect( historicalDates ).toContain( forecastStart.getTime() ); + expect( forecastDates ).toContain( forecastStart.getTime() ); + } ); + + test( 'historical only when forecastStart is after all data', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-10' ), value: 20 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2024-02-01' ), + } ) + ); + + expect( result.current.historical ).toHaveLength( 2 ); + expect( result.current.forecast ).toHaveLength( 0 ); + expect( result.current.bandData ).toHaveLength( 0 ); + } ); + + test( 'forecast only when forecastStart is before all data', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-10' ), value: 20 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2023-12-01' ), + } ) + ); + + expect( result.current.historical ).toHaveLength( 0 ); + expect( result.current.forecast ).toHaveLength( 2 ); + } ); + + test( 'band data requires both valid lower AND upper bounds', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10, lower: 5, upper: 15 }, + { date: new Date( '2024-01-10' ), value: 20, lower: 15, upper: 25 }, + { date: new Date( '2024-01-15' ), value: 30, lower: null, upper: 35 }, + { date: new Date( '2024-01-20' ), value: 40, lower: 35, upper: null }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors, + forecastStart: new Date( '2024-01-01' ), + } ) + ); + + // Only the first two points have both bounds + expect( result.current.bandData ).toHaveLength( 2 ); + expect( result.current.bandData[ 0 ] ).toEqual( { + date: new Date( '2024-01-05' ), + lower: 5, + upper: 15, + } ); + expect( result.current.bandData[ 1 ] ).toEqual( { + date: new Date( '2024-01-10' ), + lower: 15, + upper: 25, + } ); + } ); + + test( 'points with only one bound are in forecast but excluded from bandData', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-10' ), value: 20, lower: 15, upper: null }, + { date: new Date( '2024-01-15' ), value: 30, lower: null, upper: 35 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors, + forecastStart: new Date( '2024-01-01' ), + } ) + ); + + expect( result.current.forecast ).toHaveLength( 2 ); + expect( result.current.bandData ).toHaveLength( 0 ); + } ); + + test( 'asNumber guard treats NaN and Infinity as null', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-10' ), value: 20, lower: NaN, upper: 25 }, + { date: new Date( '2024-01-15' ), value: 30, lower: 25, upper: Infinity }, + { date: new Date( '2024-01-20' ), value: 40, lower: -Infinity, upper: 45 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors, + forecastStart: new Date( '2024-01-01' ), + } ) + ); + + // None have both valid lower AND upper after asNumber guard + expect( result.current.bandData ).toHaveLength( 0 ); + + // But all are in forecast (they're still valid points) + expect( result.current.forecast ).toHaveLength( 3 ); + } ); + + test( 'yDomain includes band bounds beyond just values', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 50, lower: 10, upper: 90 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors, + forecastStart: new Date( '2024-01-01' ), + } ) + ); + + const [ yMin, yMax ] = result.current.yDomain; + // Lower bound (10) should pull yMin below value (50) + expect( yMin ).toBeLessThan( 10 ); + // Upper bound (90) should push yMax above value (50) + expect( yMax ).toBeGreaterThan( 90 ); + } ); + + test( 'yDomain has minimum padding of 1 for flat data', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 50 }, + { date: new Date( '2024-01-10' ), value: 50 }, + { date: new Date( '2024-01-15' ), value: 50 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2024-02-01' ), + } ) + ); + + const [ yMin, yMax ] = result.current.yDomain; + // Padding should be at least 1 even though range is 0 + expect( yMax - yMin ).toBeGreaterThanOrEqual( 2 ); + expect( yMin ).toBe( 49 ); + expect( yMax ).toBe( 51 ); + } ); + + test( 'originalIndex preserves pre-sort position', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-20' ), value: 30 }, // index 0 in input + { date: new Date( '2024-01-05' ), value: 10 }, // index 1 in input + { date: new Date( '2024-01-10' ), value: 20 }, // index 2 in input + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2024-02-01' ), + } ) + ); + + // After sorting by date: Jan 5 (idx 1), Jan 10 (idx 2), Jan 20 (idx 0) + expect( result.current.allPoints[ 0 ].originalIndex ).toBe( 1 ); + expect( result.current.allPoints[ 1 ].originalIndex ).toBe( 2 ); + expect( result.current.allPoints[ 2 ].originalIndex ).toBe( 0 ); + } ); + + test( 'band data only comes from forecast region', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10, lower: 5, upper: 15 }, + { date: new Date( '2024-01-15' ), value: 20, lower: 15, upper: 25 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors, + forecastStart: new Date( '2024-01-10' ), + } ) + ); + + // First point is historical (before forecastStart), so only the second appears in bandData + expect( result.current.bandData ).toHaveLength( 1 ); + expect( result.current.bandData[ 0 ].date.getTime() ).toBe( + new Date( '2024-01-15' ).getTime() + ); + } ); + + test( 'xDomain spans the full date range', () => { + const data: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-20' ), value: 30 }, + ]; + + const { result } = renderHook( () => + useForecastData( { + data, + accessors: accessorsNoBounds, + forecastStart: new Date( '2024-01-10' ), + } ) + ); + + expect( result.current.xDomain[ 0 ].getTime() ).toBe( new Date( '2024-01-05' ).getTime() ); + expect( result.current.xDomain[ 1 ].getTime() ).toBe( new Date( '2024-01-20' ).getTime() ); + } ); +} ); From f66a8536aca9107d839d1d68abaacdbb3c2f9051 Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 15:50:05 +1100 Subject: [PATCH 4/6] Add integration tests for TimeSeriesForecastChart component --- .../test/time-series-forecast-chart.test.tsx | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 projects/js-packages/charts/src/charts/time-series-forecast-chart/test/time-series-forecast-chart.test.tsx diff --git a/projects/js-packages/charts/src/charts/time-series-forecast-chart/test/time-series-forecast-chart.test.tsx b/projects/js-packages/charts/src/charts/time-series-forecast-chart/test/time-series-forecast-chart.test.tsx new file mode 100644 index 000000000000..43f68a253ae6 --- /dev/null +++ b/projects/js-packages/charts/src/charts/time-series-forecast-chart/test/time-series-forecast-chart.test.tsx @@ -0,0 +1,151 @@ +import { render, screen } from '@testing-library/react'; +import { TimeSeriesForecastChartUnresponsive } from '..'; +import { GlobalChartsProvider } from '../../../providers'; + +// Mock useElementHeight to return a non-zero height in jsdom so charts render +const mockRefCallback = jest.fn(); +jest.mock( '../../../hooks/use-element-height', () => ( { + useElementHeight: () => [ mockRefCallback, 300 ], +} ) ); + +type TestDatum = { + date: Date; + value: number; + lower?: number | null; + upper?: number | null; +}; + +const accessors = { + x: ( d: TestDatum ) => d.date, + y: ( d: TestDatum ) => d.value, + yLower: ( d: TestDatum ) => d.lower ?? null, + yUpper: ( d: TestDatum ) => d.upper ?? null, +}; + +const sampleData: TestDatum[] = [ + { date: new Date( '2024-01-05' ), value: 10 }, + { date: new Date( '2024-01-10' ), value: 20 }, + { date: new Date( '2024-01-15' ), value: 30, lower: 25, upper: 35 }, + { date: new Date( '2024-01-20' ), value: 40, lower: 35, upper: 45 }, +]; + +const defaultProps = { + data: sampleData, + accessors, + forecastStart: new Date( '2024-01-12' ), + width: 600, + height: 300, +}; + +const renderChart = ( props = {} ) => + render( + + + + ); + +describe( 'TimeSeriesForecastChart', () => { + describe( 'Empty data', () => { + test( 'renders "No data available" text', () => { + renderChart( { data: [] } ); + expect( screen.getByText( /no data available/i ) ).toBeInTheDocument(); + } ); + + test( 'renders container with testid', () => { + renderChart( { data: [] } ); + expect( screen.getByTestId( 'time-series-forecast-chart' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'Normal render', () => { + test( 'renders chart container with testid', () => { + renderChart(); + expect( screen.getByTestId( 'time-series-forecast-chart' ) ).toBeInTheDocument(); + } ); + } ); + + describe( 'Forecast divider', () => { + test( 'present when both historical and forecast data exist', () => { + renderChart(); + expect( screen.getByTestId( 'forecast-divider' ) ).toBeInTheDocument(); + } ); + + test( 'not present when forecastStart is after all data (historical only)', () => { + renderChart( { forecastStart: new Date( '2024-02-01' ) } ); + expect( screen.queryByTestId( 'forecast-divider' ) ).not.toBeInTheDocument(); + } ); + + test( 'not present when forecastStart is before all data (forecast only)', () => { + renderChart( { forecastStart: new Date( '2023-12-01' ) } ); + expect( screen.queryByTestId( 'forecast-divider' ) ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'Legend', () => { + test( 'hidden by default (showLegend=false)', () => { + renderChart(); + expect( screen.queryByTestId( /legend/ ) ).not.toBeInTheDocument(); + } ); + + test( 'visible with series labels when showLegend=true', () => { + renderChart( { showLegend: true } ); + expect( screen.getByText( 'Historical' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Forecast' ) ).toBeInTheDocument(); + } ); + + test( 'shows custom series labels', () => { + renderChart( { + showLegend: true, + seriesKeys: { historical: 'Actual', forecast: 'Predicted' }, + } ); + expect( screen.getByText( 'Actual' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Predicted' ) ).toBeInTheDocument(); + } ); + + test( 'band series does not appear in legend', () => { + renderChart( { showLegend: true } ); + expect( screen.queryByText( 'Uncertainty' ) ).not.toBeInTheDocument(); + } ); + + test( 'shows only Historical when forecastStart is after all data', () => { + renderChart( { + showLegend: true, + forecastStart: new Date( '2024-02-01' ), + } ); + expect( screen.getByText( 'Historical' ) ).toBeInTheDocument(); + expect( screen.queryByText( 'Forecast' ) ).not.toBeInTheDocument(); + } ); + + test( 'shows only Forecast when forecastStart is before all data', () => { + renderChart( { + showLegend: true, + forecastStart: new Date( '2023-12-01' ), + } ); + expect( screen.getByText( 'Forecast' ) ).toBeInTheDocument(); + expect( screen.queryByText( 'Historical' ) ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'Custom className', () => { + test( 'applies custom className to container', () => { + renderChart( { className: 'my-custom-chart' } ); + const container = screen.getByTestId( 'time-series-forecast-chart' ); + expect( container ).toHaveClass( 'my-custom-chart' ); + } ); + } ); + + describe( 'Rendering', () => { + test( 'renders with animation prop without errors', () => { + renderChart( { animation: true } ); + expect( screen.getByTestId( 'time-series-forecast-chart' ) ).toBeInTheDocument(); + } ); + + test( 'renders with custom dimensions', () => { + renderChart( { width: 800, height: 400 } ); + const container = screen.getByTestId( 'time-series-forecast-chart' ); + expect( container ).toBeInTheDocument(); + expect( container ).toHaveStyle( { width: '800px' } ); + expect( container ).toHaveStyle( { height: '400px' } ); + } ); + } ); +} ); From a6b4c99d1696e3863daa477d87c9248a9f06257a Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 16:11:50 +1100 Subject: [PATCH 5/6] Update changelog for docs and tests PR --- .../charts/changelog/add-time-series-forecast-chart | 4 ---- .../charts/changelog/add-time-series-forecast-chart-docs | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 projects/js-packages/charts/changelog/add-time-series-forecast-chart create mode 100644 projects/js-packages/charts/changelog/add-time-series-forecast-chart-docs diff --git a/projects/js-packages/charts/changelog/add-time-series-forecast-chart b/projects/js-packages/charts/changelog/add-time-series-forecast-chart deleted file mode 100644 index f1aef3b57aae..000000000000 --- a/projects/js-packages/charts/changelog/add-time-series-forecast-chart +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Add TimeSeriesForecastChart component for visualizing time series data with forecast periods. diff --git a/projects/js-packages/charts/changelog/add-time-series-forecast-chart-docs b/projects/js-packages/charts/changelog/add-time-series-forecast-chart-docs new file mode 100644 index 000000000000..9c64bbbbd0d4 --- /dev/null +++ b/projects/js-packages/charts/changelog/add-time-series-forecast-chart-docs @@ -0,0 +1,3 @@ +Significance: patch +Type: changed +Comment: Add documentation and tests for TimeSeriesForecastChart, no user-facing changes. From be4a64d1eb8316e9ce313deb322607a6f3f5710d Mon Sep 17 00:00:00 2001 From: annacmc Date: Tue, 17 Feb 2026 18:21:57 +1100 Subject: [PATCH 6/6] Restore feature changelog entry deleted during rebase --- .../charts/changelog/add-time-series-forecast-chart | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/js-packages/charts/changelog/add-time-series-forecast-chart diff --git a/projects/js-packages/charts/changelog/add-time-series-forecast-chart b/projects/js-packages/charts/changelog/add-time-series-forecast-chart new file mode 100644 index 000000000000..f1aef3b57aae --- /dev/null +++ b/projects/js-packages/charts/changelog/add-time-series-forecast-chart @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add TimeSeriesForecastChart component for visualizing time series data with forecast periods.