Skip to content

Commit 3fce851

Browse files
committed
metrics: Chart MRR and ARR side by side
1 parent 38474cd commit 3fce851

11 files changed

Lines changed: 233 additions & 89 deletions

File tree

clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/analytics/costs/CostsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export default function ClientPage({
262262
endDate: dateRange.to,
263263
interval: 'day',
264264
organization_id: organization.id,
265-
metrics: costMetrics as string[],
265+
metrics: costMetrics,
266266
...(customerIds && customerIds.length > 0
267267
? { customer_id: customerIds }
268268
: {}),

clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/analytics/metrics/[metric]/ClientPage.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export default function ClientPage({
8080
interval,
8181
organization_id: organizationId,
8282
...(productId && productId.length > 0 ? { product_id: productId } : {}),
83-
metrics,
83+
metrics: metrics,
8484
})
8585

8686
if (!data) {
@@ -99,7 +99,11 @@ export default function ClientPage({
9999
productId={productId ?? undefined}
100100
/>
101101
) : (
102-
<MetricGroup metricKeys={metrics} data={data} interval={interval} />
102+
<MetricGroup
103+
metricKeys={metrics}
104+
data={data}
105+
interval={interval}
106+
/>
103107
)}
104108
</div>
105109
)

clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/analytics/metrics/components/MetricGroup.tsx

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { ParsedMetricsResponse } from '@/hooks/queries'
55
import { schemas } from '@polar-sh/client'
66
import { useRef, useState } from 'react'
77
import { twMerge } from 'tailwind-merge'
8+
import { MetricEntry } from './metrics-config'
89

910
interface MetricGroupProps {
1011
data?: ParsedMetricsResponse
11-
metricKeys: (keyof schemas['Metrics'])[]
12+
metricKeys: MetricEntry[]
1213
interval: schemas['TimeInterval']
1314
loading?: boolean
1415
}
@@ -29,32 +30,37 @@ export function MetricGroup({
2930
<div className="flex flex-col gap-y-6">
3031
<div className="dark:border-polar-700 flex flex-col overflow-hidden rounded-2xl border border-gray-200">
3132
<div className="grid grid-cols-1 flex-col [clip-path:inset(1px_1px_1px_1px)] md:grid-cols-2 lg:grid-cols-3">
32-
{metricKeys.map((metricKey, index) => (
33-
<MetricChartBox
34-
key={String(metricKey)}
35-
data={data}
36-
interval={interval}
37-
metric={metricKey}
38-
height={200}
39-
chartType="line"
40-
loading={loading}
41-
hoveredPeriodIndex={hoveredPeriodIndex}
42-
onHoverPeriodChange={(period) => {
43-
if (period !== null) {
44-
activeHoverKey.current = String(metricKey)
45-
setHoveredPeriodIndex(period)
46-
} else if (activeHoverKey.current === String(metricKey)) {
47-
activeHoverKey.current = null
48-
setHoveredPeriodIndex(null)
49-
}
50-
}}
51-
className={twMerge(
52-
'rounded-none! bg-transparent dark:bg-transparent',
53-
index === 0 && 'lg:col-span-2',
54-
'dark:border-polar-700 border-t-0 border-r border-b border-l-0 border-gray-200 shadow-none',
55-
)}
56-
/>
57-
))}
33+
{metricKeys.map((entry, index) => {
34+
const keys = Array.isArray(entry) ? entry : [entry]
35+
const primaryKey = keys[0]
36+
return (
37+
<MetricChartBox
38+
key={String(primaryKey)}
39+
data={data}
40+
interval={interval}
41+
metric={primaryKey}
42+
metrics={keys.length > 1 ? keys : undefined}
43+
height={200}
44+
chartType="line"
45+
loading={loading}
46+
hoveredPeriodIndex={hoveredPeriodIndex}
47+
onHoverPeriodChange={(period) => {
48+
if (period !== null) {
49+
activeHoverKey.current = String(primaryKey)
50+
setHoveredPeriodIndex(period)
51+
} else if (activeHoverKey.current === String(primaryKey)) {
52+
activeHoverKey.current = null
53+
setHoveredPeriodIndex(null)
54+
}
55+
}}
56+
className={twMerge(
57+
'rounded-none! bg-transparent dark:bg-transparent',
58+
index === 0 && 'lg:col-span-2',
59+
'dark:border-polar-700 border-t-0 border-r border-b border-l-0 border-gray-200 shadow-none',
60+
)}
61+
/>
62+
)
63+
})}
5864
</div>
5965
</div>
6066
</div>

clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/analytics/metrics/components/metrics-config.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@ export function isValidMetricType(value: string): value is MetricType {
1616
return METRIC_TYPES.includes(value as MetricType)
1717
}
1818

19-
const SUBSCRIPTION_METRICS: (keyof schemas['Metrics'])[] = [
20-
'monthly_recurring_revenue',
21-
'committed_monthly_recurring_revenue',
19+
export type MetricEntry =
20+
| keyof schemas['Metrics']
21+
| (keyof schemas['Metrics'])[]
22+
23+
const SUBSCRIPTION_METRICS: MetricEntry[] = [
24+
['monthly_recurring_revenue', 'committed_monthly_recurring_revenue'],
25+
['annual_recurring_revenue', 'committed_annual_recurring_revenue'],
2226
'active_subscriptions',
2327
'new_subscriptions',
2428
'committed_subscriptions',
@@ -96,7 +100,7 @@ export function getMetricsForType(
96100
hasRecurringProducts?: boolean
97101
hasOneTimeProducts?: boolean
98102
},
99-
): (keyof schemas['Metrics'])[] {
103+
): MetricEntry[] {
100104
switch (metricType) {
101105
case 'subscriptions':
102106
return SUBSCRIPTION_METRICS

clients/apps/web/src/app/(main)/dashboard/[organization]/(header)/customers/[customerId]/meter/[meterId]/CustomerMeterPage.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -267,11 +267,13 @@ const CustomerMeterPage = ({
267267
}
268268
interval={interval}
269269
height={300}
270-
metric={{
271-
slug: 'quantity',
272-
display_name: meter.name,
273-
type: 'scalar',
274-
}}
270+
metrics={[
271+
{
272+
slug: 'quantity',
273+
display_name: meter.name,
274+
type: 'scalar',
275+
},
276+
]}
275277
showYAxis
276278
chartType={meter.aggregation.func === 'count' ? 'bar' : 'line'}
277279
/>

clients/apps/web/src/components/Customer/CustomerMeter.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,13 @@ export const CustomerMeter = ({
120120
data={quantities as unknown as ParsedMetricPeriod[]}
121121
interval="day"
122122
height={250}
123-
metric={{
124-
slug: 'quantity',
125-
display_name: meter.name,
126-
type: 'scalar',
127-
}}
123+
metrics={[
124+
{
125+
slug: 'quantity',
126+
display_name: meter.name,
127+
type: 'scalar',
128+
},
129+
]}
128130
showYAxis
129131
chartType={meter.aggregation.func === 'count' ? 'bar' : 'line'}
130132
/>

clients/apps/web/src/components/Meter/MeterPage.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,13 @@ export const MeterPage = ({
130130
}
131131
interval={interval}
132132
height={400}
133-
metric={{
134-
slug: 'quantity',
135-
display_name: meter.name,
136-
type: 'scalar',
137-
}}
133+
metrics={[
134+
{
135+
slug: 'quantity',
136+
display_name: meter.name,
137+
type: 'scalar',
138+
},
139+
]}
138140
chartType={
139141
meter.aggregation.func === 'count' ? 'bar' : 'line'
140142
}

clients/apps/web/src/components/Metrics/MetricChart.tsx

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface MetricChartProps {
1111
data: ParsedMetricPeriod[]
1212
previousData?: ParsedMetricPeriod[]
1313
interval: schemas['TimeInterval']
14-
metric: schemas['Metric']
14+
metrics: schemas['Metric'][]
1515
height?: number
1616
width?: number
1717
grid?: boolean
@@ -27,7 +27,7 @@ const MetricChart = ({
2727
data,
2828
previousData,
2929
interval,
30-
metric,
30+
metrics,
3131
height,
3232
width,
3333
grid,
@@ -42,40 +42,50 @@ const MetricChart = ({
4242

4343
const genericData = useMemo(
4444
() =>
45-
data.map((period, index) => ({
46-
timestamp: period.timestamp.toISOString(),
47-
current:
48-
period[metric.slug as keyof Omit<ParsedMetricPeriod, 'timestamp'>],
49-
...(previousData && previousData[index]
50-
? {
51-
previous:
52-
previousData[index][
53-
metric.slug as keyof Omit<ParsedMetricPeriod, 'timestamp'>
54-
],
55-
}
56-
: {}),
57-
})),
58-
[data, previousData, metric.slug],
45+
data.map((period, index) => {
46+
const point: Record<string, unknown> = {
47+
timestamp: period.timestamp.toISOString(),
48+
}
49+
for (const m of metrics) {
50+
point[m.slug] =
51+
period[m.slug as keyof Omit<ParsedMetricPeriod, 'timestamp'>]
52+
}
53+
if (metrics.length === 1 && previousData?.[index]) {
54+
point['previous'] =
55+
previousData[index][
56+
metrics[0].slug as keyof Omit<ParsedMetricPeriod, 'timestamp'>
57+
]
58+
}
59+
return point
60+
}),
61+
[data, previousData, metrics],
5962
)
6063

6164
const series = useMemo(
62-
() => [
63-
...(previousData
64-
? [
65+
() =>
66+
metrics.length > 1
67+
? metrics.map((m, i) => ({
68+
key: m.slug,
69+
label: m.display_name,
70+
color: i === 0 ? '#2563eb' : isDark ? '#383942' : '#ccc',
71+
}))
72+
: [
73+
...(previousData
74+
? [
75+
{
76+
key: 'previous',
77+
label: 'Previous Period',
78+
color: isDark ? '#383942' : '#ccc',
79+
},
80+
]
81+
: []),
6582
{
66-
key: 'previous',
67-
label: 'Previous Period',
68-
color: isDark ? '#383942' : '#ccc',
83+
key: metrics[0].slug,
84+
label: 'Current Period',
85+
color: '#2563eb',
6986
},
70-
]
71-
: []),
72-
{
73-
key: 'current',
74-
label: 'Current Period',
75-
color: '#2563eb',
76-
},
77-
],
78-
[previousData, isDark],
87+
],
88+
[metrics, previousData, isDark],
7989
)
8090

8191
const timestampFormatter = useMemo(() => {
@@ -85,8 +95,8 @@ const MetricChart = ({
8595
}, [interval])
8696

8797
const valueFormatter = useMemo(
88-
() => (value: number) => getFormattedMetricValue(metric, value),
89-
[metric],
98+
() => (value: number) => getFormattedMetricValue(metrics[0], value),
99+
[metrics],
90100
)
91101

92102
const ticks = useMemo((): AxisTick[] | undefined => {

clients/apps/web/src/components/Metrics/MetricChartBox.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface MetricOption {
3636

3737
interface MetricChartBoxProps {
3838
metric: keyof schemas['Metrics']
39+
metrics?: (keyof schemas['Metrics'])[]
3940
onMetricChange?: (metric: keyof schemas['Metrics']) => void
4041
data?: ParsedMetricsResponse
4142
previousData?: ParsedMetricsResponse
@@ -69,6 +70,7 @@ const EXPERIMENTAL_METRICS: Record<string, { tooltip: string }> = {
6970
const MetricChartBox = ({
7071
ref,
7172
metric,
73+
metrics: metricsProp,
7274
onMetricChange,
7375
data,
7476
previousData,
@@ -107,6 +109,13 @@ const MetricChartBox = ({
107109
}, [previousData])
108110

109111
const selectedMetric = useMemo(() => data?.metrics[metric], [data, metric])
112+
const resolvedMetrics = useMemo(() => {
113+
if (!data) return []
114+
const keys = metricsProp ?? [metric]
115+
return keys
116+
.map((k) => data.metrics[k])
117+
.filter((m): m is schemas['Metric'] => m != null)
118+
}, [data, metric, metricsProp])
110119
const [hoveredPeriodIndexLocal, setHoveredPeriodIndexLocal] = React.useState<
111120
number | null
112121
>(null)
@@ -344,14 +353,14 @@ const MetricChartBox = ({
344353
style={{ height }}
345354
className="flex flex-col items-center justify-center"
346355
/>
347-
) : data && selectedMetric ? (
356+
) : data && resolvedMetrics.length > 0 ? (
348357
<MetricChart
349358
height={height}
350359
width={width}
351360
data={data.periods}
352361
previousData={previousData?.periods}
353362
interval={interval}
354-
metric={selectedMetric}
363+
metrics={resolvedMetrics}
355364
onDataIndexHover={handleDataIndexHover}
356365
simple={simple}
357366
chartType={chartType}

0 commit comments

Comments
 (0)