Skip to content

Commit 66c5114

Browse files
committed
feat: extend product icon treatment to product groupings
Add getProductIcon, getProductIconSvg, getGroupIcon, getGroupIconSvg generalized utilities. Charts, table, and hero cards now show product icons for both sku and product group-by dimensions (PlayIcon for Actions, CopilotIcon, SparkleIcon, PackageIcon, FileIcon for LFS).
1 parent 83dbfdb commit 66c5114

6 files changed

Lines changed: 59 additions & 27 deletions

File tree

src/components/HeroCardsGrid.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { HeroCard } from './HeroCard';
33
import type { ReportSchema } from '../lib/report-schema';
44
import type { ReportSummary, AnyReportRow, TokenUsageRow } from '../lib/types';
55
import { REPORT_TYPES } from '../lib/types';
6-
import { formatCurrency, formatCompact, formatDisplayValue, getSkuIcon } from '../lib/formatters';
6+
import { formatCurrency, formatCompact, formatDisplayValue, getGroupIcon } from '../lib/formatters';
77
import { topN } from '../lib/aggregation';
88
import styles from '../App.module.css';
99

@@ -69,11 +69,10 @@ export function HeroCardsGrid({ schema, summary, visibleRows, reportType }: Hero
6969
);
7070
return top3.map((m) => {
7171
const label = formatDisplayValue(m.key, card.breakdownGroupField!);
72-
const isSkuBreakdown = card.breakdownGroupField === 'sku';
73-
const SkuIcon = isSkuBreakdown ? getSkuIcon(m.key) : null;
72+
const GroupIcon = getGroupIcon(m.key, card.breakdownGroupField!);
7473
return (
7574
<span key={m.key}>
76-
<span>{SkuIcon && <SkuIcon size={14} />}{label}</span>
75+
<span>{GroupIcon && <GroupIcon size={14} />}{label}</span>
7776
<span>{card.format === 'currency' ? formatCurrency(m.value) : formatCompact(m.value)}</span>
7877
</span>
7978
);

src/components/ReportTable.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { OnboardingBubble, ONBOARDING_STEPS } from './onboarding';
1919
import { type ActionListItemInput } from '@primer/react/deprecated';
2020
import { useReport } from '../context/useReport';
2121
import { groupBy, sumBy } from '../lib/aggregation';
22-
import { formatCurrency, formatCompact, humanizeColumn, formatDisplayValue, getAvatarUrl, formatDatetime, getSkuIcon } from '../lib/formatters';
22+
import { formatCurrency, formatCompact, humanizeColumn, formatDisplayValue, getAvatarUrl, formatDatetime, getGroupIcon } from '../lib/formatters';
2323
import type { AnyReportRow, TokenUsageRow, UsageReportRow } from '../lib/types';
2424
import { REPORT_TYPES } from '../lib/types';
2525
import { getModelIconUrl } from '../lib/chart-theme';
@@ -372,7 +372,8 @@ export function ReportTable({ onGroupClick }: ReportTableProps) {
372372
</button>
373373
);
374374
}
375-
const ColumnIcon = groupByColumn === 'sku' ? getSkuIcon(value) : COLUMN_ICONS[groupByColumn];
375+
const dynamicIcon = getGroupIcon(value, groupByColumn);
376+
const ColumnIcon = dynamicIcon ?? COLUMN_ICONS[groupByColumn];
376377
return (
377378
<button
378379
type="button"

src/components/charts/CostBreakdownChart.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ActionList, ActionMenu } from '@primer/react';
55
import { useReport } from '../../context/useReport';
66
import { groupBy, sumBy, timeBucket as bucketRows } from '../../lib/aggregation';
77
import { buildColorMap } from '../../lib/chart-theme';
8-
import { formatDisplayValue, formatCompact, bucketKeyToTimestamp, getSkuIconSvg } from '../../lib/formatters';
8+
import { formatDisplayValue, formatCompact, bucketKeyToTimestamp, getGroupIconSvg } from '../../lib/formatters';
99
import type { MetricOption } from '../../lib/report-schema';
1010
import type { AnyReportRow, BillingRow } from '../../lib/types';
1111
import styles from './Charts.module.css';
@@ -53,20 +53,20 @@ export function CostBreakdownChart({ stackField = 'model', metricOptions }: Cost
5353

5454
const seriesColor = colorMap.get(groupInfo.group) ?? '#808fa3';
5555
const displayName = formatDisplayValue(groupInfo.group, stackField) || ' ';
56-
const isSku = stackField === 'sku';
57-
const iconHtml = isSku ? getSkuIconSvg(groupInfo.group, seriesColor) : '';
56+
const hasIcons = stackField === 'sku' || stackField === 'product';
57+
const iconHtml = hasIcons ? getGroupIconSvg(groupInfo.group, stackField, seriesColor) : '';
5858
return {
5959
type: 'column' as const,
6060
name: iconHtml
6161
? `<span style="display:flex;align-items:center;gap:4px">${iconHtml}${displayName}</span>`
6262
: displayName,
6363
data,
6464
color: seriesColor,
65-
...(isSku && {
65+
...(hasIcons && {
6666
tooltip: {
6767
pointFormatter: function (this: Highcharts.Point) {
6868
const val = this.y ?? 0;
69-
const icon = getSkuIconSvg(groupInfo.group, String(this.color));
69+
const icon = getGroupIconSvg(groupInfo.group, stackField, String(this.color));
7070
const formatted = activeMetric.isCurrency
7171
? `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
7272
: formatCompact(val);
@@ -102,7 +102,7 @@ export function CostBreakdownChart({ stackField = 'model', metricOptions }: Cost
102102
: '<tr style="border-top: 1px solid var(--borderColor-muted, #d1d9e0b3);"><td><b>Total:&nbsp;</b></td><td style="text-align: right;"><b>{point.total:,.0f}</b></td></tr></table>',
103103
},
104104
plotOptions: { column: { stacking: 'normal' } },
105-
legend: stackField === 'sku'
105+
legend: stackField === 'sku' || stackField === 'product'
106106
? { symbolWidth: 0, symbolHeight: 0, symbolPadding: 0 }
107107
: { symbolWidth: 16, symbolHeight: 12, symbolPadding: 5 },
108108
series,

src/components/charts/ModelBreakdownChart.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ActionList, ActionMenu, SegmentedControl } from '@primer/react';
55
import { CopilotIcon, CreditCardIcon } from '@primer/octicons-react';
66
import { useReport } from '../../context/useReport';
77
import { groupBy, sumBy, topN } from '../../lib/aggregation';
8-
import { humanizeColumn, formatCompact, formatDisplayValue, getAvatarUrl, getSkuIconSvg } from '../../lib/formatters';
8+
import { humanizeColumn, formatCompact, formatDisplayValue, getAvatarUrl, getGroupIconSvg } from '../../lib/formatters';
99
import { buildColorMap, getModelIconUrl } from '../../lib/chart-theme';
1010
import { REPORT_TYPES } from '../../lib/types';
1111
import type { MetricOption } from '../../lib/report-schema';
@@ -93,8 +93,8 @@ export function GroupBreakdownChart({ stackField = 'model', metricOptions }: Gro
9393

9494
const seriesColor = colorMap.get(stackInfo.stack) ?? '#808fa3';
9595
const displayName = formatDisplayValue(stackInfo.stack, stackField) || ' ';
96-
const isSku = stackField === 'sku';
97-
const iconHtml = isSku ? getSkuIconSvg(stackInfo.stack, seriesColor) : '';
96+
const hasIcons = stackField === 'sku' || stackField === 'product';
97+
const iconHtml = hasIcons ? getGroupIconSvg(stackInfo.stack, stackField, seriesColor) : '';
9898
return {
9999
type: 'bar' as const,
100100
name: iconHtml
@@ -103,11 +103,11 @@ export function GroupBreakdownChart({ stackField = 'model', metricOptions }: Gro
103103
data,
104104
color: seriesColor,
105105
visible: !isHidden,
106-
...(isSku && {
106+
...(hasIcons && {
107107
tooltip: {
108108
pointFormatter: function (this: Highcharts.Point) {
109109
const val = this.y ?? 0;
110-
const icon = getSkuIconSvg(stackInfo.stack, String(this.color));
110+
const icon = getGroupIconSvg(stackInfo.stack, stackField, String(this.color));
111111
const formatted = activeMetric.isCurrency
112112
? `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
113113
: formatCompact(val);
@@ -136,17 +136,17 @@ export function GroupBreakdownChart({ stackField = 'model', metricOptions }: Gro
136136
const name = typeof this.value === 'string' ? this.value : String(this.value);
137137
const isAvatar = groupByColumn === 'username' || groupByColumn === 'organization';
138138
const isModel = groupByColumn === 'model';
139-
const isSku = groupByColumn === 'sku';
139+
const hasIcons = groupByColumn === 'sku' || groupByColumn === 'product';
140140
if (isAvatar && name) {
141141
return `<span style="display:inline-flex;align-items:center;gap:6px;">${name}<img src="${getAvatarUrl(name)}" width="16" height="16" style="border-radius:50%;" loading="lazy" /></span>`;
142142
}
143143
if (isModel && name) {
144144
return `<span style="display:inline-flex;align-items:center;gap:6px;">${name}<img src="${getModelIconUrl(name)}" width="16" height="16" style="border-radius:50%;" loading="lazy" /></span>`;
145145
}
146-
if (isSku) {
146+
if (hasIcons) {
147147
// Find the raw SKU key from the sorted data for this category index
148148
const rawKey = sorted[typeof this.pos === 'number' ? this.pos : 0]?.key ?? '';
149-
const icon = rawKey ? getSkuIconSvg(rawKey) : '';
149+
const icon = rawKey ? getGroupIconSvg(rawKey, groupByColumn) : '';
150150
return icon ? `<span style="display:inline-flex;align-items:center;gap:4px;">${icon}${name}</span>` : name;
151151
}
152152
return name || ' ';
@@ -174,7 +174,7 @@ export function GroupBreakdownChart({ stackField = 'model', metricOptions }: Gro
174174
: '<tr style="border-top: 1px solid var(--borderColor-muted, #d1d9e0b3);"><td><b>Total:&nbsp;</b></td><td style="text-align: right;"><b>{point.total:,.0f}</b></td></tr></table>',
175175
},
176176
plotOptions: { bar: { stacking: 'normal' } },
177-
legend: stackField === 'sku'
177+
legend: stackField === 'sku' || stackField === 'product'
178178
? { symbolWidth: 0, symbolHeight: 0, symbolPadding: 0 }
179179
: { symbolWidth: 16, symbolHeight: 12, symbolPadding: 5 },
180180
series,

src/components/charts/TimeSeriesChart.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { HighchartsReact } from 'highcharts-react-official';
44
import { ActionList, ActionMenu, SegmentedControl } from '@primer/react';
55
import { useReport } from '../../context/useReport';
66
import { groupBy, sumBy, timeBucket as bucketRows } from '../../lib/aggregation';
7-
import { humanizeColumn, formatDisplayValue, formatCompact, bucketKeyToTimestamp, getSkuIconSvg } from '../../lib/formatters';
7+
import { humanizeColumn, formatDisplayValue, formatCompact, bucketKeyToTimestamp, getGroupIconSvg } from '../../lib/formatters';
88
import { buildColorMap } from '../../lib/chart-theme';
99
import { getStoredValue, setStoredValue, STORAGE_KEYS } from '../../lib/local-storage';
1010
import type { MetricOption } from '../../lib/report-schema';
@@ -134,8 +134,8 @@ export function TimeSeriesChart({ metricOptions }: { metricOptions?: MetricOptio
134134
}
135135

136136
const displayName = formatDisplayValue(group.key, groupByColumn) || ' ';
137-
const isSku = groupByColumn === 'sku';
138-
const icon = isSku ? getSkuIconSvg(group.key, color) : '';
137+
const hasIcons = groupByColumn === 'sku' || groupByColumn === 'product';
138+
const icon = hasIcons ? getGroupIconSvg(group.key, groupByColumn, color) : '';
139139

140140
series.push({
141141
type: 'line' as const,
@@ -155,7 +155,7 @@ export function TimeSeriesChart({ metricOptions }: { metricOptions?: MetricOptio
155155
? `$${val.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
156156
: formatCompact(val);
157157
return isSku
158-
? `${getSkuIconSvg(group.key, String(this.color))} ${displayName}: <b>${formatted}</b><br/>`
158+
? `${getGroupIconSvg(group.key, groupByColumn, String(this.color))} ${displayName}: <b>${formatted}</b><br/>`
159159
: `<span style="color:${this.color}">●</span> ${displayName}: <b>${formatted}</b><br/>`;
160160
},
161161
},
@@ -180,7 +180,7 @@ export function TimeSeriesChart({ metricOptions }: { metricOptions?: MetricOptio
180180
},
181181
},
182182
series,
183-
legend: groupByColumn === 'sku'
183+
legend: groupByColumn === 'sku' || groupByColumn === 'product'
184184
? { symbolWidth: 0, symbolHeight: 0, symbolPadding: 0 }
185185
: { symbolWidth: 16, symbolHeight: 12, symbolPadding: 5 },
186186
chart: { height: 400 },

src/lib/formatters.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ComponentType } from 'react';
22
import { createElement } from 'react';
33
import { renderToStaticMarkup } from 'react-dom/server';
4-
import { AiModelIcon, ContainerIcon, CopilotIcon, DatabaseIcon, FileIcon, PackageIcon, PlayIcon, TagIcon } from '@primer/octicons-react';
4+
import { AiModelIcon, ContainerIcon, CopilotIcon, DatabaseIcon, FileIcon, PackageIcon, PlayIcon, SparkleIcon, TagIcon } from '@primer/octicons-react';
55

66
type OcticonComponent = ComponentType<{ size?: number; className?: string; fill?: string }>;
77

@@ -19,6 +19,38 @@ export function getSkuIconSvg(rawValue: string, color?: string): string {
1919
return renderIcon(icon, color);
2020
}
2121

22+
/** Map a raw product key to the relevant Octicon */
23+
export function getProductIcon(rawValue: string): OcticonComponent {
24+
const v = rawValue.toLowerCase();
25+
if (v === 'actions') return PlayIcon;
26+
if (v === 'copilot') return CopilotIcon;
27+
if (v === 'spark') return SparkleIcon;
28+
if (v === 'git_lfs') return FileIcon;
29+
if (v === 'packages') return PackageIcon;
30+
return TagIcon;
31+
}
32+
33+
/** Get an inline SVG string for a product (for Highcharts legend/tooltip HTML) */
34+
export function getProductIconSvg(rawValue: string, color?: string): string {
35+
const icon = getProductIcon(rawValue);
36+
if (icon === TagIcon) return '';
37+
return renderIcon(icon, color);
38+
}
39+
40+
/** Columns that have dedicated icon functions */
41+
export function getGroupIconSvg(rawValue: string, column: string, color?: string): string {
42+
if (column === 'sku') return getSkuIconSvg(rawValue, color);
43+
if (column === 'product') return getProductIconSvg(rawValue, color);
44+
return '';
45+
}
46+
47+
/** Get the React icon component for a group value */
48+
export function getGroupIcon(rawValue: string, column: string): OcticonComponent | null {
49+
if (column === 'sku') { const i = getSkuIcon(rawValue); return i === TagIcon ? null : i; }
50+
if (column === 'product') { const i = getProductIcon(rawValue); return i === TagIcon ? null : i; }
51+
return null;
52+
}
53+
2254
/** Map a raw SKU key to the product-relevant Octicon */
2355
export function getSkuIcon(rawValue: string): OcticonComponent {
2456
const v = rawValue.toLowerCase();

0 commit comments

Comments
 (0)