Skip to content

feat(ui): add analytics chart components (Scorecard, Pie/Donut, Bar, Line/Area, DataTable) - #148

Open
goodbounties-nanoclaw-agent[bot] wants to merge 12 commits into
mainfrom
feat/analytics-components
Open

feat(ui): add analytics chart components (Scorecard, Pie/Donut, Bar, Line/Area, DataTable)#148
goodbounties-nanoclaw-agent[bot] wants to merge 12 commits into
mainfrom
feat/analytics-components

Conversation

@goodbounties-nanoclaw-agent

Copy link
Copy Markdown
Contributor

Summary

Implements all 5 analytics components planned across #139/#141 (Scorecard) and
#143#147 (Pie/Donut, Bar, Line/Area, DataTable): themeable, cross-platform
chart/table primitives in packages/ui, each following the same conventions
(createComponent, theme-token color resolution, golden-ratio type scale,
bare/card variants, Storybook story + Playwright smoke test + baseline
screenshot).

Shared utility extractions

  • resolveThemeColor (packages/ui/src/utils/resolveThemeColor.ts) —
    theme-token-to-raw-color resolution, factored out once PieDonutChart became
    the 2nd consumer after Scorecard, rather than duplicating a 5th time.
    Scorecard itself is untouched (out of scope, already shipped).
  • CHART_FONT_FAMILY (packages/ui/src/utils/chartFontFamily.ts) — shared
    font-family constant applied to every SVG <Text> in BarChart and
    LineAreaChart, matching the default preset's sans-serif stack instead of the
    browser's serif fallback (react-native-svg's Text sits outside Tamagui's
    styling system, the same reason resolveThemeColor exists for fill/stroke).

PieDonutChart fixes (post-implementation QA)

  • Center-value truncation: centerContentMaxWidth was derived from
    ringRadius * (1 - innerRadius), which shrinks as the donut hole grows
    instead of growing with it — ringRadius is the stroke's centerline, not
    the hole radius. Fixed to compute the actual hole radius
    (ringRadius - strokeWidth / 2) and size the available square off that.
    Also defaulted the center value's own formatting to 0 decimals ("450K", not
    "450.0K") so typical aggregated totals fit the hole at the component's
    default size.
  • innerRadius default: the Default story was overriding the spec
    default to a flat pie; restored to the spec default (innerRadius=0.6,
    donut with center metric) and moved the flat-pie case to its own PurePie
    story.

formatMetricValue fix

Whole-number compact values now render without decimals at every scale
("892K", not "892.0K"), mirroring the pre-existing below-K-threshold integer
check that already did this for raw values under 1,000.

Test plan

  • pnpm --filter @goodwidget/ui build and lint — no new errors
  • Full monorepo pnpm build succeeds
  • Storybook stories for all 5 components render correctly across variants
    and edge-case states (empty, single-point, near-equal split, stress/
    large-N, null values), visually compared against design references
  • Playwright smoke tests pass for all 5 components; full-page baseline
    screenshots committed (tests/design-system/test-results/story-*.png)
  • Unit tests for formatMetricValue / snapshot tests — same open
    question flagged on the Scorecard PR: no JS unit-test framework exists
    in this repo yet; Playwright/Storybook smoke coverage is what this PR
    relies on instead.

Closes #143, #144, #145, #146
Refs #139, #147

Supersedes #142 (that PR's head branch was deleted during a rename earlier in
this work and GitHub does not re-link a PR to a recreated same-named branch —
opened fresh here with the identical, already-reviewed description).

goodbounties-nanoclaw-agent Bot and others added 12 commits August 5, 2026 12:47
Adds a reusable Scorecard component to packages/ui — a single-metric KPI
card with label, formatted value, optional trend indicator, and bare/card
variants. Implements #139's compact/decimal/none formatting modes via a
shared formatMetricValue utility, and a golden-ratio modular typography
scale (base 24px, ratio 1.618, 12px floor) across sm/md/lg sizes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- formatMetricValue: fix a rounding-boundary bug where a scaled value could
  round up to the next unit's threshold without promoting (e.g. 999_950 ->
  "1000.0K" instead of "1.0M"); guard non-finite input (NaN/Infinity) with a
  "--" fallback shared by all consumers; note the intentional en-US locale.
- Scorecard: set both testID and data-testid so React Native test tooling
  and web DOM queries both work; mark the trend arrow SVG as decorative
  (accessible=false / aria-hidden) since adjacent text already conveys
  direction; give resolveThemeColor a visible fallback and dev warning
  instead of silently rendering an empty fill; type its theme param via
  ReturnType<typeof useTheme> instead of an unsafe double cast.
- package.json: move react-native-svg to peerDependencies (kept in
  devDependencies for local build/test) to avoid native-module duplication
  for consumers.
- Storybook story + smoke test: render all 5 mock-data rows in both the
  bare and card variants with per-row testIDs, and assert all 10 are
  visible so the test matches what it claims to cover.

Co-Authored-By: Claude <noreply@anthropic.com>
…ing pass

- formatMetricValue: whole numbers below the compact threshold render
  without decimals ("47", not "47.0").
- Scorecard card variant: vertically centers content regardless of
  whether a trend row is present.
- Value text uses $color instead of $primary; prefix/suffix render at
  fontWeight 400 in $placeholderColor, subordinate to the value.
- Card variant elevation now uses a lightness-overlay + top highlight
  instead of a hard border, scoped to Scorecard's <Card> call site so
  the shared Card primitive is untouched.
- Spacing (label-to-value gap, value-to-trend gap, card padding) now
  derives from the same golden-ratio constants as the type scale.
- Bump the Scorecard smoke test's viewport height to fit the taller
  card layout without clipping the screenshot.

Co-Authored-By: Claude <noreply@anthropic.com>
Second consumer (PieDonutChart, and the 3 charts after it) needs the same
theme-token-to-raw-color resolution Scorecard already does privately.
Extracted to packages/ui/src/utils/resolveThemeColor.ts rather than
duplicating a 5th time, mirroring how formatMetricValue was shared.
Scorecard.tsx is untouched — it already shipped under a prior issue and
migrating it is out of this task's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Generalizes FundingDistributionChart's SVG arc technique into a themeable
packages/ui primitive: computed ring geometry supports innerRadius from 0
(filled pie) to a thin donut, sort/maxSlices "Other" aggregation, legend
with percentages, empty/stress states, and onSegmentPress.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Vertical/horizontal categorical bar chart with nice-number axis ticks,
zero-baseline-aware single-edge-rounded bars, graceful long-label
truncation, and sub-pixel-safe rendering for large category counts.
Follows Scorecard/PieDonutChart conventions (createComponent, theme
color resolution, golden-ratio type scale).

Also adds a Text primitive to the Storybook react-native-svg web shim
— needed because BarChart is the first chart to render labels inside
the SVG (for pixel-precise axis alignment) rather than via Tamagui
Text, and the shim never had to support that primitive before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Time-series line/area chart with linear/monotone/step interpolation,
optional area-fill gradient, reference lines, multi-series with an
optional secondary y-axis, null-gap handling (connectNulls), and
adaptive x-axis label thinning/truncation for dense datasets, mirroring
BarChart's established truncateLabelToWidth pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Shared CHART_FONT_FAMILY constant applied to every SVG <Text> in
  BarChart and LineAreaChart, matching the default preset's sans-serif
  typography stack instead of the browser's default serif fallback
  (react-native-svg's Text isn't part of Tamagui's styling system, same
  reason resolveThemeColor exists for fill/stroke).
- PieDonutChart's Default story now shows the spec default (innerRadius
  0.6, donut with center metric) instead of overriding it to a flat
  pie; the pie mode moves to its own PurePie story.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
centerContentMaxWidth was derived from ringRadius * (1 - innerRadius),
which shrinks as the hole grows instead of growing with it — ringRadius
is the stroke's centerline, not the hole radius. Compute the actual hole
radius (ringRadius - strokeWidth / 2) and size the available square off
that instead. Also default the center value's own formatting to 0
decimals (450K, not 450.0K) so typical aggregated totals fit the hole at
the component's default size.

Co-Authored-By: Claude <noreply@anthropic.com>
Pure-Tamagui data table with typed columns, sorting, striping, sticky
header, and horizontal/vertical scroll. Applies the design-hierarchy
rules from the Pie/Bar/Line fix round: muted $placeholderColor headers
(bold weight kept per spec's own rule 4) vs full-contrast $color data
values.

Co-Authored-By: Claude <noreply@anthropic.com>
formatMetricValue's compact formatter always applied toFixed(decimals) to
the scaled value, even when it landed on a whole unit (892000 -> "892.0K"
instead of "892K"). Mirrors the same whole-number check already used in the
below-threshold branch. Affects BarChart/LineAreaChart axis labels and
DataTable cells.

Also backfills missing Playwright screenshot coverage requested in QA:
Pie/Donut empty + stress, Bar empty, Line/Area step/multi-series/gap/empty/
single-point, DataTable empty/null-cells/card variant.

Co-Authored-By: Claude <noreply@anthropic.com>
…pped elements

Several new QA-round screenshots (Pie/Donut, Bar, Line/Area empty states,
Line/Area step/single-point, DataTable empty/nulls) used element-level
locator screenshots instead of the established screenshotStory() full-iframe
helper, producing tightly-cropped composites inconsistent with the rest of
the suite.

DataTable/Default now uses a taller viewport (1800px) so the card variant,
previously captured as a separate cropped shot, renders within the same
full-page capture as the bare table.

Co-Authored-By: Claude <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot could not run the full agentic suite for this review because it was automatically requested on a bot-authored pull request. Request a review from Copilot under Reviewers to retry with the full agentic suite. Improved support for bot-authored pull requests is coming soon.

This PR introduces a new “Analytics” component suite in @goodwidget/ui (Scorecard + chart/table primitives), adds shared formatting/theme utilities, and expands Storybook + smoke coverage for these stories.

Changes:

  • Added analytics primitives: Scorecard, PieDonutChart, BarChart, LineAreaChart, and DataTable, plus Storybook stories for each.
  • Extracted/shared utilities for chart rendering (formatMetricValue, resolveThemeColor, CHART_FONT_FAMILY) and exported them from the package entrypoint.
  • Expanded Playwright Storybook smoke tests and updated the Storybook react-native-svg web shim to support more SVG primitives.

Reviewed changes

Copilot reviewed 17 out of 39 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/design-system/smoke.spec.ts Adds Storybook smoke coverage (visibility assertions + screenshots) for the new analytics stories
packages/ui/src/utils/resolveThemeColor.ts New shared theme-token-to-raw-color helper for SVG-based chart components
packages/ui/src/utils/formatMetricValue.ts New shared numeric formatter for metrics (compact/decimal/none)
packages/ui/src/utils/chartFontFamily.ts Adds shared SVG font-family constant for consistent chart text rendering
packages/ui/src/index.ts Exports the new analytics components and related types/utilities
packages/ui/src/components/Scorecard.tsx Adds Scorecard primitive (value + label + optional trend glyph)
packages/ui/src/components/PieDonutChart.tsx Adds donut/pie chart with legend, aggregation, and empty state
packages/ui/src/components/BarChart.tsx Adds vertical/horizontal bar chart with axes/grid and empty state
packages/ui/src/components/LineAreaChart.tsx Adds multi-series line/area chart with interpolation, gaps, secondary axis, and reference lines
packages/ui/src/components/DataTable.tsx Adds sortable, scrollable data table with empty state and compact mode
packages/ui/package.json Adds react-native-svg peer/dev dependency for chart rendering
examples/storybook/src/stories/design-system/*.stories.tsx Adds Storybook stories for the new analytics primitives
examples/storybook/src/shims/reactNativeSvg.tsx Extends the web shim to support <Text> rotation/origin and gradient primitives
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

* consumer (PieDonutChart) needed the same logic, mirroring how
* formatMetricValue was already shared rather than duplicated.
*/
import type { useTheme } from 'tamagui'
* missing, so a bad token renders in a visible (if wrong) color instead of
* silently disappearing as black-on-web / transparent-on-native.
*/
export function resolveThemeColor(theme: ReturnType<typeof useTheme>, token: string): string {
Comment on lines +94 to +113
function resolveThemeColor(theme: ReturnType<typeof useTheme>, token: string): string {
const themeRecord = theme as unknown as Record<string, { val?: unknown } | string | undefined>
const key = token.replace('$', '')
const themeValue = themeRecord[key]
const resolved =
themeValue && typeof themeValue === 'object' && 'val' in themeValue
? String(themeValue.val)
: typeof themeValue === 'string'
? themeValue
: undefined

if (resolved) {
return resolved
}

console.warn(`Scorecard: theme token "${token}" not found, falling back to "$color"`)

const fallback = themeRecord.color
return fallback && typeof fallback === 'object' && 'val' in fallback ? String(fallback.val) : '#000000'
}
key={runIndex}
d={areaPath}
fill={`url(#${gradientIdPrefix}-${seriesItem.key})`}
fillOpacity={areaOpacity / 0.15}
gap="$2"
minHeight={onSegmentPress ? LEGEND_ROW_MIN_HEIGHT_PX : undefined}
cursor={onSegmentPress ? 'pointer' : undefined}
onPress={onSegmentPress ? () => onSegmentPress({ label: segment.label, value: segment.value }, index) : undefined}
Comment on lines +291 to +300
<XStack
key={`${segment.label}-${index}`}
alignItems="center"
gap="$2"
minHeight={onSegmentPress ? LEGEND_ROW_MIN_HEIGHT_PX : undefined}
cursor={onSegmentPress ? 'pointer' : undefined}
onPress={onSegmentPress ? () => onSegmentPress({ label: segment.label, value: segment.value }, index) : undefined}
role={onSegmentPress ? 'button' : undefined}
aria-label={onSegmentPress ? `View ${segment.label} detail` : undefined}
>
Comment on lines +41 to +42
const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined
const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ')
Comment on lines +336 to +338
sortedData.map((row, index) => (
<XStack
key={index}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add Pie/Donut chart component

1 participant