@micha.bigler/ui-core-micha 2.30.0 → 2.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,31 @@
1
1
  import React from 'react';
2
2
  import { Box } from '@mui/material';
3
- import { LineChart as MuiLineChart } from '@mui/x-charts/LineChart';
3
+ import { useTheme } from '@mui/material/styles';
4
+ import {
5
+ LineChart as MuiLineChart,
6
+ MarkElement as MuiMarkElement,
7
+ } from '@mui/x-charts/LineChart';
4
8
  import { useNeutralChartPalette } from './palette';
9
+ import {
10
+ DEFAULT_LEGEND_POSITION,
11
+ spaceForRotatedTicks,
12
+ withAxisDefaults,
13
+ withChartSlotDefaults,
14
+ withGridDefaults,
15
+ } from './chartDefaults';
5
16
 
6
- function labelledAxis(axes, label, defaults) {
7
- const values = axes?.length ? axes : [defaults];
8
- return values.map((axis) => ({ ...axis, label: axis.label || label }));
17
+ // Fixes MUI's hollow `background.paper`-filled marker (MarkElement.js's own
18
+ // `styled()` default). CHART-6's WO named a `MuiMarkElement` theme-token
19
+ // override in src/theme/tokens.js as the preferred mechanism, with a
20
+ // per-wrapper slots/sx override as the documented fallback. This uses the
21
+ // fallback deliberately, not because the theme route was infeasible: a
22
+ // wrapper-level fix applies to every consumer immediately on this version
23
+ // bump, including the 14 apps that haven't adopted `createAppTheme` yet
24
+ // (THEME-1 is a separate, optional pin bump) -- a theme-token override would
25
+ // only take effect for apps on the new baseline theme. Composes correctly
26
+ // with a caller's own `slots.mark` (spread after this default, so it wins).
27
+ function FilledMarkElement({ color, style, ...props }) {
28
+ return <MuiMarkElement {...props} color={color} style={{ fill: color, ...style }} />;
9
29
  }
10
30
 
11
31
  /**
@@ -21,23 +41,56 @@ export function LineChart({
21
41
  palette,
22
42
  minHeight,
23
43
  aspect,
44
+ grid,
45
+ hideLegend = series.length <= 1,
46
+ legendPosition = DEFAULT_LEGEND_POSITION,
47
+ margin,
48
+ slots,
49
+ slotProps,
24
50
  ...chartProps
25
51
  }) {
52
+ const theme = useTheme();
26
53
  const neutralPalette = useNeutralChartPalette();
27
54
  if (!xAxisLabel || !yAxisLabel) {
28
55
  throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
29
56
  }
30
57
 
58
+ const labelledXAxis = withAxisDefaults(
59
+ xAxis,
60
+ xAxisLabel,
61
+ { scaleType: 'point', data: [] },
62
+ theme.typography.caption.fontSize,
63
+ );
64
+ const labelledYAxis = withAxisDefaults(
65
+ yAxis,
66
+ yAxisLabel,
67
+ { scaleType: 'linear' },
68
+ theme.typography.caption.fontSize,
69
+ );
70
+ const rotatedTickSpace = spaceForRotatedTicks(
71
+ labelledXAxis,
72
+ margin,
73
+ theme.typography.caption.lineHeight,
74
+ );
75
+ const defaultedSeries = series.map((item) => ({
76
+ showMark: false,
77
+ labelMarkType: 'square',
78
+ ...item,
79
+ }));
80
+
31
81
  return (
32
82
  <Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
33
83
  <MuiLineChart
34
84
  {...chartProps}
35
- series={series}
36
- xAxis={labelledAxis(xAxis, xAxisLabel, { scaleType: 'point', data: [] })}
37
- yAxis={labelledAxis(yAxis, yAxisLabel, { scaleType: 'linear' })}
85
+ series={defaultedSeries}
86
+ xAxis={rotatedTickSpace.xAxis}
87
+ yAxis={labelledYAxis}
38
88
  colors={palette || neutralPalette.categorical}
39
- hideLegend={series.length <= 1}
40
- slotProps={{ tooltip: { trigger: 'axis' } }}
89
+ grid={withGridDefaults(grid)}
90
+ hideLegend={hideLegend}
91
+ margin={rotatedTickSpace.margin}
92
+ slots={{ mark: FilledMarkElement, ...slots }}
93
+ slotProps={withChartSlotDefaults(slotProps, legendPosition)}
41
94
  />
42
95
  </Box>
43
96
  );
@@ -80,6 +80,22 @@ const RANGE_OPTIONS = [
80
80
  { key: '1y', granularity: 'month', labelKey: 'TimeSeriesChart.RANGE_1_YEAR' },
81
81
  ];
82
82
 
83
+ function SeriesToggleDot({ color, active }) {
84
+ return (
85
+ <Box
86
+ component="span"
87
+ data-testid="series-colour-dot"
88
+ sx={{
89
+ width: (theme) => theme.spacing(1.5),
90
+ height: (theme) => theme.spacing(1.5),
91
+ borderRadius: '50%',
92
+ bgcolor: color,
93
+ opacity: active ? 1 : 0.35,
94
+ }}
95
+ />
96
+ );
97
+ }
98
+
83
99
  /**
84
100
  * A time-series preset on ChartFrame + BarChart: a range picker (emits the
85
101
  * selected range AND its mapped granularity to the host) and series toggle
@@ -106,6 +122,7 @@ export function TimeSeriesChart({
106
122
  error = false,
107
123
  onRangeChange,
108
124
  defaultRange = '1w',
125
+ skipAnimation,
109
126
  }) {
110
127
  const { t } = useTranslation();
111
128
  const palette = useNeutralChartPalette().categorical;
@@ -221,6 +238,8 @@ export function TimeSeriesChart({
221
238
  size="small"
222
239
  checked={visibleKeys.has(series.key)}
223
240
  onChange={() => toggleSeries(series.key)}
241
+ icon={<SeriesToggleDot color={palette[index % palette.length]} active={false} />}
242
+ checkedIcon={<SeriesToggleDot color={palette[index % palette.length]} active />}
224
243
  sx={{
225
244
  color: palette[index % palette.length],
226
245
  '&.Mui-checked': { color: palette[index % palette.length] },
@@ -261,6 +280,8 @@ export function TimeSeriesChart({
261
280
  }))}
262
281
  palette={visiblePalette}
263
282
  height={CHART_HEIGHT}
283
+ hideLegend={seriesConfig.length > 0}
284
+ skipAnimation={skipAnimation}
264
285
  />
265
286
  )}
266
287
  </ChartFrame>
@@ -0,0 +1,120 @@
1
+ export const DEFAULT_LEGEND_POSITION = {
2
+ vertical: 'bottom',
3
+ horizontal: 'start',
4
+ };
5
+
6
+ export function withGridDefaults(grid) {
7
+ return { horizontal: true, ...grid };
8
+ }
9
+
10
+ export function withAxisDefaults(axes, label, defaults, tickFontSize) {
11
+ const values = axes?.length ? axes : [defaults];
12
+ return values.map((axis) => ({
13
+ // `defaults.scaleType` (and any other scale-defining default) must
14
+ // survive even when the caller passes their OWN xAxis/yAxis array --
15
+ // confirmed live in the dev harness: a caller-supplied axis with `data`
16
+ // but no `scaleType` previously lost 'point'/'band' entirely (only
17
+ // applied when the whole array was absent), leaving MUI's scale
18
+ // undefined and every point position NaN (`<path> attribute d:
19
+ // Expected number, "MNaN,..."` in the real rendered SVG -- a mocked
20
+ // prop-assertion test cannot catch this, it only reproduces on an
21
+ // actual MUI scale computation).
22
+ ...defaults,
23
+ ...axis,
24
+ tickLabelStyle: {
25
+ fontSize: tickFontSize,
26
+ ...axis.tickLabelStyle,
27
+ },
28
+ label: axis.label || label,
29
+ }));
30
+ }
31
+
32
+ function cssLengthToPixels(value) {
33
+ if (typeof value === 'number') return value;
34
+ if (typeof value !== 'string') return 0;
35
+
36
+ const parsed = Number.parseFloat(value);
37
+ if (!Number.isFinite(parsed)) return 0;
38
+ if (value.endsWith('rem') || value.endsWith('em')) return parsed * 16;
39
+ return parsed;
40
+ }
41
+
42
+ const MUI_LABELLED_X_AXIS_HEIGHT = 45;
43
+ const MUI_CHART_MARGIN_BOTTOM = 20;
44
+ const AVERAGE_GLYPH_WIDTH_EM = 0.6;
45
+
46
+ function formattedTickText(axis, value) {
47
+ if (!axis.valueFormatter) return String(value);
48
+ try {
49
+ return String(axis.valueFormatter(value, { location: 'tick' }));
50
+ } catch {
51
+ // Some formatters require MUI's complete scale context. Falling back to
52
+ // the raw value is conservative: it reserves at least as much space.
53
+ return String(value);
54
+ }
55
+ }
56
+
57
+ function rotatedTickMetrics(axis, defaultLineHeight) {
58
+ const angle = Number(axis.tickLabelStyle?.angle) || 0;
59
+ if (angle === 0) return null;
60
+
61
+ const fontSize = cssLengthToPixels(axis.tickLabelStyle?.fontSize);
62
+ if (fontSize === 0) return null;
63
+
64
+ const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
65
+ const longestLabel = (axis.data || []).reduce((longest, value) => {
66
+ const text = formattedTickText(axis, value);
67
+ return text.length > longest.length ? text : longest;
68
+ }, '');
69
+ const textWidth = Math.max(fontSize, longestLabel.length * fontSize * AVERAGE_GLYPH_WIDTH_EM);
70
+ const radians = (angle * Math.PI) / 180;
71
+ const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
72
+ + Math.abs(Math.sin(radians)) * textWidth;
73
+ const extraHeight = Math.max(1, Math.ceil(projectedHeight - fontSize));
74
+
75
+ return { extraHeight };
76
+ }
77
+
78
+ /**
79
+ * Rotated labels project both their line box and text width vertically.
80
+ * X-Charts uses xAxis.height when deciding whether a label fits, while margin
81
+ * only separates the axis from the SVG edge, so both surfaces must grow.
82
+ * Explicit caller height and bottom-margin values always remain untouched.
83
+ */
84
+ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
85
+ let largestExtraHeight = 0;
86
+ const spacedXAxis = xAxis.map((axis) => {
87
+ const metrics = rotatedTickMetrics(axis, defaultLineHeight);
88
+ if (!metrics) return axis;
89
+
90
+ largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
91
+ if (axis.height != null) return axis;
92
+ return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
93
+ });
94
+
95
+ const callerSetBottom = typeof margin === 'number' || margin?.bottom != null;
96
+ const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
97
+ ? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
98
+ : margin;
99
+
100
+ return { xAxis: spacedXAxis, margin: spacedMargin };
101
+ }
102
+
103
+ export function withChartSlotDefaults(slotProps, legendPosition) {
104
+ const position = {
105
+ ...DEFAULT_LEGEND_POSITION,
106
+ ...legendPosition,
107
+ ...slotProps?.legend?.position,
108
+ };
109
+ return {
110
+ ...slotProps,
111
+ tooltip: {
112
+ trigger: 'axis',
113
+ ...slotProps?.tooltip,
114
+ },
115
+ legend: {
116
+ ...slotProps?.legend,
117
+ position,
118
+ },
119
+ };
120
+ }
@@ -0,0 +1,36 @@
1
+ // Short, granularity-aware x-axis tick labels.
2
+ //
3
+ // Not a style choice -- MUI X-Charts has a known, unfixed bug (mui-x#18768,
4
+ // duplicate of #18399): when the measured space for a tick label gets
5
+ // tight, its ellipsize() logic doesn't truncate the text, it collapses it
6
+ // to an empty string (the <tspan> stays in the DOM with no content). It
7
+ // only reproduces with long label text ("08/05, 10:00 AM"-style, ~16
8
+ // characters) combined with this app's actual font metrics (DM Sans) --
9
+ // short labels like "05.08." or "10:00" leave enough margin that the bug
10
+ // never triggers. Same fix already proven in jg-ferien's ActivitySection.
11
+
12
+ // `locale` defaults to "de-CH" only as a fallback for callers that don't
13
+ // pass the app's active i18n language (found in review: an earlier version
14
+ // hardcoded "de-CH" always, showing German month names to en/fr users).
15
+
16
+ export function formatShortTime(date, locale = 'de-CH') {
17
+ // hour12 forced false regardless of locale -- an English 12-hour label
18
+ // ("10:00 AM") is exactly the longer style that triggered the original
19
+ // collapse bug; staying short is the point, not locale fidelity here.
20
+ return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false });
21
+ }
22
+
23
+ export function formatShortDate(date, locale = 'de-CH') {
24
+ return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
25
+ }
26
+
27
+ export function formatShortMonth(date, locale = 'de-CH') {
28
+ return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
29
+ }
30
+
31
+ // "All" spans years -- day/month labels repeat (e.g. every "01.04." shows up
32
+ // once per year) and read as meaningless noise; a bare year number is the
33
+ // only granularity dense multi-year history can show cleanly.
34
+ export function formatShortYear(date, locale = 'de-CH') {
35
+ return date.toLocaleDateString(locale, { year: 'numeric' });
36
+ }
@@ -0,0 +1,7 @@
1
+ /** Return one January tick for every calendar year represented in dates. */
2
+ export function yearTickInterval(dates) {
3
+ const years = new Set(dates.map((date) => date.getFullYear()));
4
+ return [...years]
5
+ .sort((first, second) => first - second)
6
+ .map((year) => new Date(year, 0, 1));
7
+ }
package/src/index.js CHANGED
@@ -55,6 +55,13 @@ export { BarChart } from './components/charts/BarChart';
55
55
  export { LineChart } from './components/charts/LineChart';
56
56
  export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
57
57
  export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
58
+ export {
59
+ formatShortTime,
60
+ formatShortDate,
61
+ formatShortMonth,
62
+ formatShortYear,
63
+ } from './components/charts/chartLabels';
64
+ export { yearTickInterval } from './components/charts/yearTickInterval';
58
65
  export {
59
66
  formatPercentage,
60
67
  formatCompact,
@@ -5,3 +5,10 @@ export {
5
5
  reportThemeAdoption,
6
6
  THEME_COMPLETENESS_SURFACES,
7
7
  } from './themeCompleteness';
8
+ export {
9
+ formatShortTime,
10
+ formatShortDate,
11
+ formatShortMonth,
12
+ formatShortYear,
13
+ } from '../components/charts/chartLabels';
14
+ export { yearTickInterval } from '../components/charts/yearTickInterval';
@@ -275,6 +275,22 @@ function contrastFindings(theme) {
275
275
  white: '#FFFFFF',
276
276
  page: theme.palette?.background?.default,
277
277
  };
278
+ for (const status of STATUS_KEYS) {
279
+ const main = theme.palette?.[status]?.main;
280
+ if (!main) continue;
281
+ for (const [surface, background] of Object.entries(backgrounds)) {
282
+ const ratio = calculateContrastRatio(main, background);
283
+ if (!Number.isFinite(ratio) || ratio < 4.5) {
284
+ findings.push({
285
+ surface: `contrast.${status}.main-on-${surface}`,
286
+ reason: Number.isFinite(ratio)
287
+ ? `Expected at least 4.5:1; received ${ratio.toFixed(2)}:1.`
288
+ : 'Contrast could not be calculated from the declared colours.',
289
+ });
290
+ }
291
+ }
292
+ }
293
+
278
294
  for (const state of ['main', 'hover', 'focus', 'error']) {
279
295
  const colour = theme.palette?.controlBorder?.[state];
280
296
  if (!colour) continue;
@@ -79,7 +79,7 @@ export const BASELINE_PALETTE = {
79
79
  bg: '#E5F4E9',
80
80
  },
81
81
  warning: {
82
- ...withMainShades('#C08A2C', '#212529'),
82
+ ...withMainShades('#976100', '#FFFFFF'),
83
83
  text: '#976100',
84
84
  fill: '#C08A2C',
85
85
  fillText: '#212529',
@@ -26,7 +26,10 @@ describe('BarChart preset', () => {
26
26
 
27
27
  expect(props.xAxis[0].label).toBe('Year');
28
28
  expect(props.yAxis[0].label).toBe('Value (kg)');
29
- expect(props.slotProps).toEqual({ tooltip: { trigger: 'axis' } });
29
+ expect(props.slotProps).toEqual({
30
+ tooltip: { trigger: 'axis' },
31
+ legend: { position: { vertical: 'bottom', horizontal: 'start' } },
32
+ });
30
33
  expect(props.hideLegend).toBe(true);
31
34
  expect(screen.getByTestId('bar-chart-container').getAttribute('style') || '').not.toContain('px');
32
35
  });
@@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react';
5
5
  import { ThemeProvider, createTheme } from '@mui/material/styles';
6
6
 
7
7
  const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
8
- vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy }));
8
+ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy, MarkElement: 'path' }));
9
9
 
10
10
  import { LineChart } from '../src/components/charts/LineChart';
11
11
 
@@ -28,6 +28,9 @@ describe('LineChart preset', () => {
28
28
  expect(props.xAxis[0].label).toBe('Date');
29
29
  expect(props.yAxis[0].label).toBe('Rate (%)');
30
30
  expect(props.hideLegend).toBe(false);
31
- expect(props.slotProps).toEqual({ tooltip: { trigger: 'axis' } });
31
+ expect(props.slotProps).toEqual({
32
+ tooltip: { trigger: 'axis' },
33
+ legend: { position: { vertical: 'bottom', horizontal: 'start' } },
34
+ });
32
35
  });
33
36
  });
@@ -0,0 +1,208 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import i18next from 'i18next';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, render } from '@testing-library/react';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { ThemeProvider } from '@mui/material/styles';
8
+
9
+ const barSpy = vi.hoisted(() => vi.fn(() => null));
10
+ const lineSpy = vi.hoisted(() => vi.fn(() => null));
11
+ vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: barSpy }));
12
+ vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'path' }));
13
+
14
+ import { BarChart } from '../src/components/charts/BarChart';
15
+ import { LineChart } from '../src/components/charts/LineChart';
16
+ import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
17
+ import { createAppTheme } from '../src/theme/createAppTheme';
18
+
19
+ const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
20
+ const i18n = i18next.createInstance();
21
+ i18n.init({
22
+ lng: 'en',
23
+ resources: {
24
+ en: {
25
+ translation: {
26
+ 'TimeSeriesChart.RANGE_LABEL': 'Range',
27
+ 'TimeSeriesChart.RANGE_1_DAY': '1 day',
28
+ 'TimeSeriesChart.RANGE_1_WEEK': '1 week',
29
+ 'TimeSeriesChart.RANGE_1_MONTH': '1 month',
30
+ 'TimeSeriesChart.RANGE_1_YEAR': '1 year',
31
+ 'TimeSeriesChart.SERIES_LABEL': 'Series',
32
+ 'ChartFrame.EMPTY_DEFAULT': 'No data available.',
33
+ 'ChartFrame.LOADING': 'Loading chart.',
34
+ 'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
35
+ },
36
+ },
37
+ },
38
+ interpolation: { escapeValue: false },
39
+ });
40
+
41
+ function withProviders(children) {
42
+ return React.createElement(
43
+ ThemeProvider,
44
+ { theme },
45
+ React.createElement(I18nextProvider, { i18n }, children),
46
+ );
47
+ }
48
+
49
+ function renderBar(props = {}) {
50
+ return render(withProviders(
51
+ React.createElement(BarChart, {
52
+ xAxisLabel: 'Month',
53
+ yAxisLabel: 'Cases',
54
+ series: [{ label: 'Cases', data: [1, 2] }],
55
+ ...props,
56
+ }),
57
+ ));
58
+ }
59
+
60
+ function renderLine(props = {}) {
61
+ return render(withProviders(
62
+ React.createElement(LineChart, {
63
+ xAxisLabel: 'Month',
64
+ yAxisLabel: 'Cases',
65
+ series: [{ label: 'Cases', data: [1, 2] }],
66
+ ...props,
67
+ }),
68
+ ));
69
+ }
70
+
71
+ describe('chart chrome defaults', () => {
72
+ afterEach(() => {
73
+ cleanup();
74
+ vi.clearAllMocks();
75
+ });
76
+
77
+ it('gives minimal BarChart, LineChart, and TimeSeriesChart calls the baseline ticks and horizontal grid, with line marks off', () => {
78
+ renderBar();
79
+ const barProps = barSpy.mock.calls.at(-1)[0];
80
+ expect(barProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
81
+ expect(barProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
82
+ expect(barProps.grid).toEqual({ horizontal: true });
83
+
84
+ renderLine();
85
+ const lineProps = lineSpy.mock.calls.at(-1)[0];
86
+ expect(lineProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
87
+ expect(lineProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
88
+ expect(lineProps.grid).toEqual({ horizontal: true });
89
+ expect(lineProps.series[0].showMark).toBe(false);
90
+
91
+ render(withProviders(React.createElement(TimeSeriesChart, {
92
+ title: 'Activity',
93
+ xAxisLabel: 'Day',
94
+ yAxisLabel: 'Cases',
95
+ data: {
96
+ xLabels: ['Mon', 'Tue'],
97
+ series: [{ key: 'cases', label: 'Cases', data: [1, 2] }],
98
+ },
99
+ skipAnimation: true,
100
+ })));
101
+ const timeSeriesProps = barSpy.mock.calls.at(-1)[0];
102
+ expect(timeSeriesProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
103
+ expect(timeSeriesProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
104
+ expect(timeSeriesProps.grid).toEqual({ horizontal: true });
105
+ expect(timeSeriesProps.skipAnimation).toBe(true);
106
+ });
107
+
108
+ it('keeps caller tick font, grid, showMark, and legendPosition values instead of the defaults', () => {
109
+ const legendPosition = { vertical: 'top', horizontal: 'end' };
110
+ renderLine({
111
+ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { fontSize: 17 } }],
112
+ grid: { horizontal: false, vertical: true },
113
+ series: [{ label: 'Cases', data: [1, 2], showMark: true }],
114
+ legendPosition,
115
+ });
116
+
117
+ const props = lineSpy.mock.calls.at(-1)[0];
118
+ expect(props.xAxis[0].tickLabelStyle.fontSize).toBe(17);
119
+ expect(props.grid).toEqual({ horizontal: false, vertical: true });
120
+ expect(props.series[0].showMark).toBe(true);
121
+ expect(props.slotProps.legend.position).toEqual(legendPosition);
122
+ });
123
+
124
+ it('adds a caller-requested vertical grid to the default horizontal grid', () => {
125
+ renderLine({ grid: { vertical: true } });
126
+
127
+ expect(lineSpy.mock.calls.at(-1)[0].grid).toEqual({
128
+ horizontal: true,
129
+ vertical: true,
130
+ });
131
+ });
132
+
133
+ it('lets a caller switch off both grid directions explicitly', () => {
134
+ renderBar({ grid: { horizontal: false, vertical: false } });
135
+
136
+ expect(barSpy.mock.calls.at(-1)[0].grid).toEqual({
137
+ horizontal: false,
138
+ vertical: false,
139
+ });
140
+ });
141
+
142
+ // Found live in the dev harness (not by this mocked test, which cannot
143
+ // reproduce a real MUI scale computation): a caller-supplied xAxis with
144
+ // `data` but no `scaleType` lost the wrapper's default scaleType entirely,
145
+ // leaving MUI's point/band scale undefined and every rendered path NaN.
146
+ // This asserts the prop-level contract; the harness's "Chart defaults" /
147
+ // "Caller overrides" LineChart specimens are the real-render proof.
148
+ it('keeps the default scaleType on a caller-supplied axis that does not set one', () => {
149
+ renderLine({ xAxis: [{ data: ['Jan', 'Feb'] }] });
150
+ expect(lineSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('point');
151
+
152
+ renderBar({ xAxis: [{ data: ['Jan', 'Feb'] }] });
153
+ expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
154
+ });
155
+
156
+ it('adds a font-and-angle-derived bottom margin only for rotated tick labels', () => {
157
+ renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
158
+ const rotatedProps = barSpy.mock.calls.at(-1)[0];
159
+ expect(rotatedProps.margin.bottom).toBeGreaterThan(20);
160
+ expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
161
+
162
+ cleanup();
163
+ renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
164
+ expect(barSpy.mock.calls.at(-1)[0].margin).toBeUndefined();
165
+ });
166
+
167
+ it('uses the series colour as the fill when a caller opts line markers back in', () => {
168
+ renderLine({ series: [{ label: 'Cases', data: [1, 2], showMark: true }] });
169
+ const props = lineSpy.mock.calls.at(-1)[0];
170
+ const mark = props.slots.mark({
171
+ color: '#3D5A99',
172
+ id: 'cases',
173
+ dataIndex: 0,
174
+ shape: 'circle',
175
+ x: 10,
176
+ y: 10,
177
+ });
178
+
179
+ expect(mark.props.style.fill).toBe('#3D5A99');
180
+ expect(mark.props.style.fill).not.toBe(theme.palette.background.paper);
181
+ });
182
+
183
+ it('defaults legendPosition to bottom-start and lets a caller choose another MUI position', () => {
184
+ renderBar({ series: [{ data: [1] }, { data: [2] }] });
185
+ expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
186
+ vertical: 'bottom',
187
+ horizontal: 'start',
188
+ });
189
+
190
+ cleanup();
191
+ const requested = { vertical: 'middle' };
192
+ renderBar({ series: [{ data: [1] }, { data: [2] }], legendPosition: requested });
193
+ expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
194
+ vertical: 'middle',
195
+ horizontal: 'start',
196
+ });
197
+
198
+ cleanup();
199
+ renderBar({
200
+ series: [{ data: [1] }, { data: [2] }],
201
+ slotProps: { legend: { position: { horizontal: 'end' } } },
202
+ });
203
+ expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
204
+ vertical: 'bottom',
205
+ horizontal: 'end',
206
+ });
207
+ });
208
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ formatShortDate,
5
+ formatShortMonth,
6
+ formatShortTime,
7
+ formatShortYear,
8
+ } from '../src/components/charts/chartLabels';
9
+ import { yearTickInterval } from '../src/components/charts/yearTickInterval';
10
+
11
+ // 2026-08-05 14:07, a fixed instant so assertions aren't flaky around
12
+ // midnight/year boundaries.
13
+ const FIXED = new Date(2026, 7, 5, 14, 7, 0);
14
+
15
+ describe('chart label helpers', () => {
16
+ it('ports fitness-monitor short-label behaviour, including locale fallback and forced 24-hour time', () => {
17
+ expect(formatShortDate(FIXED, 'de-CH')).toBe('05.08.');
18
+ expect(formatShortDate(FIXED, 'de-CH').length).toBeLessThan(10);
19
+ expect(formatShortTime(FIXED, 'de-CH')).toBe('14:07');
20
+ expect(formatShortTime(FIXED, 'en-US')).toBe('14:07');
21
+ expect(formatShortMonth(FIXED, 'de-CH')).toMatch(/26$/);
22
+ expect(formatShortMonth(FIXED, 'de-CH').length).toBeLessThan(10);
23
+ expect(formatShortYear(FIXED, 'de-CH')).toBe('2026');
24
+ expect(formatShortYear(FIXED, 'en-US')).toBe('2026');
25
+ expect(formatShortDate(FIXED, 'en-US')).toBe('08/05');
26
+ expect(formatShortDate(FIXED)).toBe(formatShortDate(FIXED, 'de-CH'));
27
+ });
28
+
29
+ it('returns one tick per year and proves dense unfiltered dates produce duplicate year labels', () => {
30
+ const denseDates = [];
31
+ for (let year = 2022; year <= 2025; year += 1) {
32
+ for (let month = 0; month < 12; month += 1) {
33
+ denseDates.push(new Date(year, month, 1));
34
+ }
35
+ }
36
+
37
+ const unfilteredLabels = denseDates.map((date) => formatShortYear(date, 'de-CH'));
38
+ expect(new Set(unfilteredLabels).size).toBeLessThan(unfilteredLabels.length);
39
+
40
+ const ticks = yearTickInterval(denseDates);
41
+ expect(ticks).toHaveLength(4);
42
+ expect(ticks.every((date) => date.getMonth() === 0 && date.getDate() === 1)).toBe(true);
43
+ expect(ticks.map((date) => formatShortYear(date, 'de-CH'))).toEqual([
44
+ '2022',
45
+ '2023',
46
+ '2024',
47
+ '2025',
48
+ ]);
49
+ });
50
+ });