Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27520-dashboard-readability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/core': patch
'@object-ui/plugin-charts': patch
'@object-ui/plugin-dashboard': patch
---

Clause-②: yes (widening: @object-ui/core gains the public export formatMonthBucketLabel; no accept set moves)
Metadata-protocol: none

Dashboard numbers and chart labels stay readable on a phone:

- **KPI value.** A dataset-bound KPI tile's value now shrinks to fit its tile, the way the plain metric tiles already did, instead of being cut with an ellipsis (`$1,090,00...` on a phone). Past the smallest size it keeps the ellipsis and shows the full figure as a tooltip. A short value looks as before.
- **Month axis.** A chart over a month-bucketed date dimension labels its axis `Oct 2025` in the reader's locale, not the raw bucket key `2025-10`. The chart tooltip's header reads the same label. The key itself is unchanged in the data, so drill-through and ordering are unaffected. `@object-ui/core` adds `formatMonthBucketLabel(value, locale)`, which answers that label for a `YYYY-MM` key and `undefined` for anything else.
- **Legend swatches.** A bar or area chart's legend draws a colour swatch for every series; it drew none, because those marks fill from a gradient. A comparison (`compareTo`) series' swatch is drawn hollow, so it reads apart from the solid swatch of the series it overlays in the same colour.
- **Horizontal bar labels.** A horizontal bar chart's category labels are drawn in full on a phone, wrapped to at most two lines inside the axis, instead of cut to eight characters (`In Progr…`). A label still too long ends in an ellipsis and shows the full text as a tooltip.
3 changes: 3 additions & 0 deletions ui/packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export * from './utils/number-display.js';
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
// The display face of a `YYYY-MM` month bucket key (`Oct 2025`) — read by the
// chart axis, which keeps the key itself as the bucket's identity.
export * from './utils/date-bucket-label.js';
// The native date/time control adapters (objectui#3127), moved down from
// `@object-ui/fields` so `@object-ui/components`' data table can share them
// instead of keeping private copies (objectui#10625). `@object-ui/fields`
Expand Down
46 changes: 46 additions & 0 deletions ui/packages/core/src/utils/__tests__/date-bucket-label.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* The month bucket key's display face (objectstack#27520 row 15): a
* `month`-granularity date dimension answers `2025-10`, and an axis reads
* `Oct 2025`.
*/

import { describe, it, expect } from 'vitest';
import { formatMonthBucketLabel } from '../date-bucket-label';

describe('formatMonthBucketLabel', () => {
it('reads a month bucket key as the short month and the year, in the locale', () => {
expect(formatMonthBucketLabel('2025-10', 'en-US')).toBe('Oct 2025');
expect(formatMonthBucketLabel('2026-01', 'en-US')).toBe('Jan 2026');
expect(formatMonthBucketLabel('2025-10', 'zh-CN')).toBe(new Date(Date.UTC(2025, 9, 1)).toLocaleDateString('zh-CN', { month: 'short', year: 'numeric', timeZone: 'UTC' }));
});

it('names the month the key names whatever the time zone (formatted in UTC)', () => {
// The first of the month at UTC midnight is the previous day west of UTC;
// a local-zone face would print September for `2025-10` there.
expect(formatMonthBucketLabel('2025-10', 'en-US')).not.toMatch(/Sep/);
expect(formatMonthBucketLabel('2025-12', 'en-US')).toBe('Dec 2025');
});

it('keeps a year below 100 as written', () => {
expect(formatMonthBucketLabel('0050-06', 'en-US')).toMatch(/50/);
expect(formatMonthBucketLabel('0050-06', 'en-US')).not.toMatch(/1950/);
});

it('answers undefined for every other shape, so the caller keeps the value as written', () => {
for (const v of ['2025-10-01', '2025-Q4', '2025', '2025-13', '2025-00', '2025-1', 'In Progress', '', null, undefined, 202510]) {
expect(formatMonthBucketLabel(v, 'en-US'), String(v)).toBeUndefined();
}
});

it('answers undefined for a locale Intl refuses', () => {
expect(formatMonthBucketLabel('2025-10', 'not a locale!')).toBeUndefined();
});
});
44 changes: 44 additions & 0 deletions ui/packages/core/src/utils/date-bucket-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* date-bucket-label — the display face of a month bucket key.
*
* The analytics executor answers a `month`-granularity date dimension with a
* sort-stable bucket KEY, `YYYY-MM` (`2025-10`). The key is the row's identity:
* a drill-through filters by it and the chart orders by it, so it stays as the
* executor wrote it. What a reader sees on an axis is this face instead,
* `Oct 2025` in the display locale.
*
* Only the key shape is recognised: four digits, a dash, a month 01..12, and
* nothing else. A day (`YYYY-MM-DD`) is the date-display path's, and a quarter
* (`2025-Q4`) or a year (`2025`) already reads as written.
*
* The month is formatted in UTC from a UTC date, so no display or browser
* time zone can move it to the neighbouring month.
*/

const MONTH_BUCKET_KEY_RE = /^(\d{4})-(0[1-9]|1[0-2])$/;

/**
* `Oct 2025` for the month bucket key `2025-10`, in `locale`; `undefined` for
* any other value, or when `Intl` refuses the locale, so the caller keeps the
* key as written.
*/
export function formatMonthBucketLabel(value: unknown, locale?: string): string | undefined {
if (typeof value !== 'string' || !MONTH_BUCKET_KEY_RE.test(value)) return undefined;
const [year, month] = value.split('-').map(Number);
const date = new Date(0);
// `setUTCFullYear`, not `Date.UTC(year, …)`: the latter maps years 0-99 onto 1900+y.
date.setUTCFullYear(year, month - 1, 1);
try {
return date.toLocaleDateString(locale, { month: 'short', year: 'numeric', timeZone: 'UTC' });
} catch {
return undefined;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* Dashboard chart readability (objectstack#27520 rows 15 and 18).
*
* - A month bucket key (`2025-10`) reads `Oct 2025` on the category axis.
* - The legend draws a swatch for a bar series (whose mark fills from a
* gradient, so recharts hands the legend a `url(#…)` it cannot draw), and a
* comparison series' swatch is hollow, apart from the primary's solid one.
* - A horizontal bar's category label is drawn in full on a phone, wrapped
* into the axis width, instead of cut to eight characters; a label that
* still has to be cut carries the full text as its tooltip.
*/

import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, act } from '@testing-library/react';

vi.mock('recharts', async () => {
const actual = await vi.importActual<any>('recharts');
return {
...actual,
ResponsiveContainer: ({ children }: any) =>
React.cloneElement(children, { width: 360, height: 320 }),
};
});

import AdvancedChartImpl from './AdvancedChartImpl';
import { ChartContainer, ChartTooltipContent } from './ChartContainerImpl';

const DESKTOP_WIDTH = window.innerWidth;

afterEach(() => {
cleanup();
(window as any).innerWidth = DESKTOP_WIDTH;
});

// The legend payload registers from a layout effect; read it after a tick.
const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 60)); });

const swatches = (c: HTMLElement) =>
Array.from(c.querySelectorAll<HTMLElement>('.recharts-legend-wrapper .rounded-\\[2px\\]'));

const categoryTicks = (c: HTMLElement) =>
Array.from(c.querySelectorAll<SVGTextElement>('.recharts-yAxis .recharts-cartesian-axis-tick-value, .recharts-cartesian-axis-tick-labels text'));

describe('month bucket keys on the category axis (row 15)', () => {
it('reads `2025-10` as `Oct 2025`', () => {
const { container } = render(
<AdvancedChartImpl
chartType="line"
data={[{ month: '2025-10', n: 3 }, { month: '2025-11', n: 5 }, { month: '2025-12', n: 4 }]}
xAxisKey="month"
series={[{ dataKey: 'n', label: 'Count' }]}
isAnimationActive={false}
/>,
);
const text = container.textContent ?? '';
expect(text).toContain('Oct 2025');
expect(text).toContain('Dec 2025');
expect(text).not.toContain('2025-10');
});

it('CONTROL: a category that only looks partly like a date is drawn as written', () => {
const { container } = render(
<AdvancedChartImpl
chartType="bar"
data={[{ q: '2025-Q4', n: 3 }, { q: '2026-Q1', n: 5 }]}
xAxisKey="q"
series={[{ dataKey: 'n', label: 'Count' }]}
isAnimationActive={false}
/>,
);
expect(container.textContent).toContain('2025-Q4');
});
});

describe('legend swatches (row 15)', () => {
const compared = [
{ dataKey: 'n', label: 'Count', variant: 'current' as const },
{ dataKey: 'n__compare', label: 'Count · vs last quarter', variant: 'comparison' as const },
];
const rows = [{ stage: 'Prospecting', n: 3, n__compare: 2 }, { stage: 'Proposal', n: 5, n__compare: 4 }];

it('a bar chart draws a colour on every swatch, never a gradient reference', async () => {
const { container } = render(
<AdvancedChartImpl chartType="bar" data={rows} xAxisKey="stage" series={compared} isAnimationActive={false} />,
);
await settle();
const drawn = swatches(container);
expect(drawn).toHaveLength(2);
for (const swatch of drawn) {
const color = swatch.style.getPropertyValue('--color-bg');
expect(color).not.toBe('');
expect(color).not.toMatch(/^url\(/);
}
});

it.each(['bar', 'line', 'area'] as const)('%s: the comparison swatch is hollow and the primary one solid, in the same colour', async (chartType) => {
const { container } = render(
<AdvancedChartImpl chartType={chartType} data={rows} xAxisKey="stage" series={compared} isAnimationActive={false} />,
);
await settle();
const [primary, comparison] = swatches(container);
expect(primary.classList.contains('bg-(--color-bg)')).toBe(true);
expect(primary.getAttribute('data-swatch')).toBeNull();
expect(comparison.getAttribute('data-swatch')).toBe('comparison');
expect(comparison.classList.contains('bg-transparent')).toBe(true);
expect(comparison.classList.contains('border-(--color-bg)')).toBe(true);
// The overlay mirrors its primary's colour; the swatch shape tells them apart.
expect(comparison.style.getPropertyValue('--color-bg')).toBe(primary.style.getPropertyValue('--color-bg'));
});
});

describe('horizontal bar category labels on a phone (row 18)', () => {
const asPhone = () => {
(window as any).innerWidth = 390;
window.dispatchEvent(new Event('resize'));
};
const statuses = [
{ status: 'In Progress', n: 4 },
{ status: 'Backlog', n: 2 },
{ status: 'Waiting on the customer to confirm the delivery window', n: 1 },
];

it('draws "In Progress" whole, not cut to "In Progr…"', () => {
asPhone();
const { container } = render(
<AdvancedChartImpl chartType="horizontal-bar" data={statuses} xAxisKey="status" series={[{ dataKey: 'n', label: 'Tasks' }]} isAnimationActive={false} />,
);
const texts = categoryTicks(container).map((t) => Array.from(t.querySelectorAll('tspan')).map((s) => s.textContent).join(' '));
expect(texts).toContain('In Progress');
expect(container.textContent).not.toContain('In Progr…');
});

it('wraps a long label to at most two lines and carries the full label as its tooltip', () => {
asPhone();
const { container } = render(
<AdvancedChartImpl chartType="horizontal-bar" data={statuses} xAxisKey="status" series={[{ dataKey: 'n', label: 'Tasks' }]} isAnimationActive={false} />,
);
const long = categoryTicks(container).find((t) => t.querySelector('title'));
expect(long).toBeDefined();
expect(long!.querySelector('title')!.textContent).toBe('Waiting on the customer to confirm the delivery window');
const lines = Array.from(long!.querySelectorAll('tspan')).map((s) => s.textContent ?? '');
expect(lines).toHaveLength(2);
expect(lines[1].endsWith('…')).toBe(true);
// A label that fits carries no tooltip.
const backlog = categoryTicks(container).find((t) => t.textContent === 'Backlog');
expect(backlog?.querySelector('title')).toBeNull();
});
});

describe('vertical bar category labels on a phone (row 18)', () => {
const asPhone = () => {
(window as any).innerWidth = 390;
window.dispatchEvent(new Event('resize'));
};
const xTicks = (c: HTMLElement) =>
Array.from(c.querySelectorAll<SVGTextElement>('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value'));

it('a few buckets rotate and draw "In Progress" whole, where the phone cut drew "In Progr…"', () => {
asPhone();
const { container } = render(
<AdvancedChartImpl
chartType="bar"
data={[{ s: 'Backlog', n: 2 }, { s: 'To Do', n: 2 }, { s: 'In Progress', n: 2 }, { s: 'In Review', n: 2 }, { s: 'Done', n: 2 }]}
xAxisKey="s"
series={[{ dataKey: 'n', label: 'Tasks' }]}
isAnimationActive={false}
/>,
);
const labels = xTicks(container).map((t) => t.textContent);
expect(labels).toContain('In Progress');
expect(container.textContent).not.toContain('In Progr…');
});

it('a label still cut carries the full label as its tooltip; a whole one carries none', () => {
const long = 'Waiting on the customer to confirm';
const { container } = render(
<AdvancedChartImpl
chartType="bar"
data={[{ s: long, n: 2 }, { s: 'Done', n: 3 }]}
xAxisKey="s"
series={[{ dataKey: 'n', label: 'Tasks' }]}
isAnimationActive={false}
/>,
);
const ticks = Array.from(container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-label'));
const cut = ticks.find((t) => (t.textContent ?? '').includes('…'));
expect(cut, 'the long label was not cut, so this measures nothing').toBeDefined();
expect(cut!.querySelector('title')?.textContent).toBe(long);
const whole = ticks.find((t) => t.querySelector('.recharts-cartesian-axis-tick-value')?.textContent === 'Done');
expect(whole?.querySelector('title')).toBeNull();
});
});

describe('a lone bar series painted per category (row 15 control)', () => {
it('draws its legend label with no swatch, since no one colour names it', async () => {
const { container } = render(
<AdvancedChartImpl
chartType="bar"
data={[{ h: 'green', n: 3 }, { h: 'red', n: 1 }]}
xAxisKey="h"
series={[{ dataKey: 'n', label: 'Projects' }]}
config={{ n: { label: 'Projects' } }}
categoryColors={{ green: '#22c55e', red: '#ef4444' }}
isAnimationActive={false}
/>,
);
await settle();
expect(container.querySelector('.recharts-legend-wrapper')?.textContent).toContain('Projects');
expect(swatches(container)).toHaveLength(0);
});
});


describe('the tooltip header names a month bucket the way the axis does (objectstack#27520 row 15)', () => {
const header = (label: string) => {
const { container } = render(
<ChartContainer config={{ amount: { label: 'Amount' } }}>
<ChartTooltipContent active label={label} payload={[{ value: 5, name: 'amount', dataKey: 'amount', payload: {} }]} />
</ChartContainer>,
);
return container.querySelector('.font-medium')?.textContent;
};

it('reads `Oct 2025` for the bucket key `2025-10`', () => {
expect(header('2025-10')).toBe('Oct 2025');
});

it('CONTROL: any other category reads as written', () => {
expect(header('2025-Q4')).toBe('2025-Q4');
expect(header('In Progress')).toBe('In Progress');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,10 @@ describe('an x-axis label rotates and is cut only when its band cannot hold it (
// The cut name is still reachable whole: the pointer over its bar opens
// the tooltip, which names the category in full. recharts throttles a
// pointer move through `requestAnimationFrame`, so the frame is flushed.
expect(container.textContent).not.toContain('Needs Analysis');
// The axis draws only the cut; the full name rides in the tick's own
// tooltip (objectstack#27520) and in the chart tooltip below.
expect(labelsOf(ticks)).not.toContain('Needs Analysis');
expect(ticks[2].closest('.recharts-cartesian-axis-tick-label')?.querySelector('title')?.textContent).toBe('Needs Analysis');
const bar = container.querySelectorAll('.recharts-rectangle')[2] as SVGElement;
const x = Number(bar.getAttribute('x')) + Number(bar.getAttribute('width')) / 2;
const y = Number(bar.getAttribute('y')) + Number(bar.getAttribute('height')) / 2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ describe('AdvancedChartImpl — unreadable-data guard (framework#4033)', () => {

expect(container.querySelector('[data-chart-error="missing-category-key"]')).toBeNull();
expect(container.querySelector('svg')).not.toBeNull();
expect(container.textContent).toContain('2026-01');
// The month bucket key draws its display face (objectstack#27520).
expect(container.textContent).toContain('Jan 2026');
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
Expand Down
Loading
Loading