Feature summary
Add a PieDonutChart component to packages/ui — displays categorical data as
proportional arc segments. innerRadius > 0 renders as a donut with an optional center
metric; innerRadius = 0 renders as a classic pie. First of 4 remaining analytics chart
components completing GoodWidget's analytics visualization layer (Scorecard already
shipped in PR #142).
Problem or opportunity
GoodWidget currently has no reusable pie/donut chart primitive — the only precedent is
FundingDistributionChart in governance-widget, a one-off, non-reusable implementation.
Any widget needing to show "parts of a whole" (budget allocation, market share, chain
distribution) has to build this from scratch. PieDonutChart generalizes the SVG arc
technique into a themeable, cross-platform packages/ui primitive any widget can compose.
Proposed solution
Generalize FundingDistributionChart's SVG arc technique (Circle + strokeDasharray/
strokeDashoffset) into a standalone component following Scorecard.tsx's patterns
(createComponent, useTheme, golden-ratio spacing/type scale, formatMetricValue).
Props / API surface (MVP)
| Prop |
Type |
Required |
Default |
Description |
| data |
Array<{ label: string; value: number; color?: string }> |
Yes |
- |
Category data (absolute values) |
| title |
string |
No |
undefined |
Chart heading |
| innerRadius |
number |
No |
0.6 |
Inner radius as fraction of outer (0 = pie, 0.6 = donut) |
| centerLabel |
string |
No |
undefined |
Top text inside donut hole |
| centerValue |
string | number |
No |
undefined |
Main metric in center |
| centerValueFormatter |
(value: number) => string |
No |
formatMetricValue |
Center value formatting |
| centerSubLabel |
string |
No |
undefined |
Bottom text in center |
| maxSlices |
number |
No |
7 |
Segments beyond this aggregate into "Other" |
| otherLabel |
string |
No |
"Other" |
Aggregated remainder label |
| sort |
'descending' | 'ascending' | 'none' |
No |
'descending' |
Segment sort order |
| showLegend |
boolean |
No |
true |
Display legend below chart |
| showPercentages |
boolean |
No |
true |
Show percentage in legend items |
| onSegmentPress |
(item, index) => void |
No |
undefined |
Segment tap callback |
| variant |
'bare' | 'card' |
No |
'bare' |
Visual variant |
| testID |
string |
No |
undefined |
Testing identifier |
| accessibilityLabel |
string |
No |
auto-generated |
Screen reader description |
| width / height |
number |
No |
188 / 188 |
SVG dimensions |
Behavioral rules
- Segments render as Circle elements with strokeDasharray/strokeDashoffset (strokeWidth 20).
- Sorted descending by default; first segment starts at -90° (12 o'clock) via
G rotation="-90".
- If
data.length > maxSlices, smallest items merge into one "Other" segment (last palette color).
- Percentage =
(item.value / sum(all values)) * 100. Integers show no decimal (25%), non-integers show one (33.3%).
- Colors from
CHART_COLOR_KEYS = ['primary','success','warning','colorDim','error'] via useTheme(); custom item.color overrides; cycles if data exceeds palette length.
- Center metric formatted by
centerValueFormatter (default formatMetricValue), constrained to inner-radius width to prevent overflow.
- Empty state (no data or all values 0): grey ring (
$borderColor, 0.18 opacity), centerLabel or "No data", no legend.
- Legend: vertical stack below chart, each row = color swatch + label + percentage.
- NaN/null/negative values silently excluded from rendering and total calculation.
Shared constraints (apply to all 4 remaining chart components)
react-native-svg only — no Canvas, no Chart.js, no DOM SVG.
- Tamagui primitives (
Stack/YStack/XStack/Text/Heading) for non-SVG layout; useTheme() for all colors — zero hardcoded hex.
createComponent() for styled sub-pieces; must support React web, React Native, and Web Components pipelines.
- Follow
Scorecard.tsx's SCORECARD_BASE_SIZE_PX/GOLDEN_RATIO constants for spacing/type scale — do not invent a separate system.
- No animation (no
react-native-reanimated, no transitions). No new dependencies (react-native-svg is already a peerDependency).
- Visual quality target: Nivo Pie — polished in both light and dark themes. Grid/axis chrome muted, data elements saturated/bold, generous padding, legend dots (not squares) matching
FundingDistributionChart's 8x8–11x11 circles.
- Accessibility: root SVG
accessibilityRole="image" + accessibilityLabel; decorative elements accessible={false}; both testID and data-testid on root.
Acceptance criteria
Additional context
DO NOT:
- Copy
FundingDistributionChart wholesale — extract only the SVG arc technique
- Use Canvas or web-only APIs
- Use
Icon.tsx (web-only DOM SVG)
- Modify
Card.ts, Text.ts, Icon.tsx, theme.ts, presets.ts, config.ts, or governance-widget
Scope boundary — only create/modify:
packages/ui/src/components/PieDonutChart.tsx
packages/ui/src/index.ts (add export under // Analytics)
examples/storybook/src/stories/design-system/PieDonutChart.stories.tsx
tests/design-system/smoke.spec.ts (add cases)
Mock data: standard (funding categories), single item, two near-equal, empty, and a
120-item stress test (triggers maxSlices aggregation) — full snippets in the spec (link
below).
References:
Create the plan
Based on the above description create an execution plan in a new sub-issue and preserve the original issue content unchanged.
Do not execute the plan until given instructions to do so.
Sub-issue requirements:
- Title format:
[DRAFT][PLAN] <what issue is being planned>
- Type:
Task
- Description must start with:
<sub-issue title>
- Link the sub-issue back to this parent issue.
Instructions when creating the plan:
- Map relevant files that are too be used as reference from all repos mentioned
- import existing @GoodDollar packages
- Map new components that should be created. Assess when a new component should be created in the new savings widget package or made part of the reusable packages/ui
Plan specification required sections:
- Required states, flows, and behaviors
- Execution plan
- acceptance criteria
- human-reviewer checklist
Feature summary
Add a
PieDonutChartcomponent topackages/ui— displays categorical data asproportional arc segments.
innerRadius > 0renders as a donut with an optional centermetric;
innerRadius = 0renders as a classic pie. First of 4 remaining analytics chartcomponents completing GoodWidget's analytics visualization layer (Scorecard already
shipped in PR #142).
Problem or opportunity
GoodWidget currently has no reusable pie/donut chart primitive — the only precedent is
FundingDistributionChartingovernance-widget, a one-off, non-reusable implementation.Any widget needing to show "parts of a whole" (budget allocation, market share, chain
distribution) has to build this from scratch.
PieDonutChartgeneralizes the SVG arctechnique into a themeable, cross-platform
packages/uiprimitive any widget can compose.Proposed solution
Generalize
FundingDistributionChart's SVG arc technique (Circle + strokeDasharray/strokeDashoffset) into a standalone component following
Scorecard.tsx's patterns(
createComponent,useTheme, golden-ratio spacing/type scale,formatMetricValue).Props / API surface (MVP)
Array<{ label: string; value: number; color?: string }>(value: number) => string(item, index) => voidBehavioral rules
G rotation="-90".data.length > maxSlices, smallest items merge into one "Other" segment (last palette color).(item.value / sum(all values)) * 100. Integers show no decimal (25%), non-integers show one (33.3%).CHART_COLOR_KEYS = ['primary','success','warning','colorDim','error']viauseTheme(); customitem.coloroverrides; cycles if data exceeds palette length.centerValueFormatter(defaultformatMetricValue), constrained to inner-radius width to prevent overflow.$borderColor, 0.18 opacity),centerLabelor "No data", no legend.Shared constraints (apply to all 4 remaining chart components)
react-native-svgonly — no Canvas, no Chart.js, no DOM SVG.Stack/YStack/XStack/Text/Heading) for non-SVG layout;useTheme()for all colors — zero hardcoded hex.createComponent()for styled sub-pieces; must support React web, React Native, and Web Components pipelines.Scorecard.tsx'sSCORECARD_BASE_SIZE_PX/GOLDEN_RATIOconstants for spacing/type scale — do not invent a separate system.react-native-reanimated, no transitions). No new dependencies (react-native-svgis already a peerDependency).FundingDistributionChart's 8x8–11x11 circles.accessibilityRole="image"+accessibilityLabel; decorative elementsaccessible={false}; bothtestIDanddata-testidon root.Acceptance criteria
innerRadius > 0)innerRadius=0(no center content)maxSlices=7with 120 items produces exactly 7 segmentsvariant="card"wraps correctly (Card primitive untouched)testID+data-testidboth presentaccessibilityRole="image"on root SvgonSegmentPressfires correctlyAdditional context
DO NOT:
FundingDistributionChartwholesale — extract only the SVG arc techniqueIcon.tsx(web-only DOM SVG)Card.ts,Text.ts,Icon.tsx,theme.ts,presets.ts,config.ts, orgovernance-widgetScope boundary — only create/modify:
packages/ui/src/components/PieDonutChart.tsxpackages/ui/src/index.ts(add export under// Analytics)examples/storybook/src/stories/design-system/PieDonutChart.stories.tsxtests/design-system/smoke.spec.ts(add cases)Mock data: standard (funding categories), single item, two near-equal, empty, and a
120-item stress test (triggers
maxSlicesaggregation) — full snippets in the spec (linkbelow).
References:
FundingDistributionChart.tsx: https://github.com/GoodDollar/GoodWidget/blob/master/packages/governance-widget/src/FundingDistributionChart.tsxfeat/analytics-components)Create the plan
Based on the above description create an execution plan in a new sub-issue and preserve the original issue content unchanged.
Do not execute the plan until given instructions to do so.
Sub-issue requirements:
[DRAFT][PLAN] <what issue is being planned>Task<sub-issue title>Instructions when creating the plan:
Plan specification required sections: