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
Open
feat(ui): add analytics chart components (Scorecard, Pie/Donut, Bar, Line/Area, DataTable)#148goodbounties-nanoclaw-agent[bot] wants to merge 12 commits into
goodbounties-nanoclaw-agent[bot] wants to merge 12 commits into
Conversation
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>
goodbounties-nanoclaw-agent
Bot
requested review from
a team,
L03TJ3 and
Copilot
August 7, 2026 18:03
Contributor
There was a problem hiding this comment.
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, andDataTable, 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-svgweb 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} |
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/cardvariants, Storybook story + Playwright smoke test + baselinescreenshot).
formatMetricValuecompact/decimal/none formatting.
innerRadius0 (pie) todonut, sort/
maxSlices"Other" aggregation, legend + percentages,empty/stress states,
onSegmentPress.ticks, zero-baseline-aware single-edge rounding, long-label truncation,
sub-pixel-safe rendering at large category counts.
interpolation, optional area-fill gradient, reference lines, multi-series
with optional secondary y-axis, null-gap handling (
connectNulls), adaptivex-axis label thinning/truncation.
horizontal/vertical scroll, numeric or percentage-string column
width.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) — sharedfont-family constant applied to every SVG
<Text>in BarChart andLineAreaChart, matching the default preset's sans-serif stack instead of the
browser's serif fallback (
react-native-svg'sTextsits outside Tamagui'sstyling system, the same reason
resolveThemeColorexists for fill/stroke).PieDonutChart fixes (post-implementation QA)
centerContentMaxWidthwas derived fromringRadius * (1 - innerRadius), which shrinks as the donut hole growsinstead of growing with it —
ringRadiusis the stroke's centerline, notthe 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.
innerRadiusdefault: theDefaultstory was overriding the specdefault 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
PurePiestory.
formatMetricValuefixWhole-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 buildandlint— no new errorspnpm buildsucceedsand edge-case states (empty, single-point, near-equal split, stress/
large-N, null values), visually compared against design references
screenshots committed (
tests/design-system/test-results/story-*.png)formatMetricValue/ snapshot tests — same openquestion 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).