Skip to content

Commit 0e0465d

Browse files
committed
metrics: Expand seats page with more customer information
1 parent 91d5aaf commit 0e0465d

7 files changed

Lines changed: 390 additions & 53 deletions

File tree

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
} from 'nuqs'
1313
import { useMemo } from 'react'
1414
import { CancellationsContent } from '../components/CancellationsContent'
15+
import { CustomersContent } from '../components/CustomersContent'
1516
import { MetricGroup } from '../components/MetricGroup'
1617
import {
1718
CANCELLATION_METRICS,
19+
CUSTOMERS_ALL_METRICS,
1820
getMetricsForType,
1921
MetricType,
2022
} from '../components/metrics-config'
@@ -63,16 +65,14 @@ export default function ClientPage({
6365

6466
const [productId] = useQueryState('product_id', parseAsArrayOf(parseAsString))
6567

66-
const metrics = useMemo(
67-
() =>
68-
metric === 'cancellations'
69-
? CANCELLATION_METRICS
70-
: getMetricsForType(metric, {
71-
hasRecurringProducts,
72-
hasOneTimeProducts,
73-
}),
74-
[metric, hasRecurringProducts, hasOneTimeProducts],
75-
)
68+
const metrics = useMemo(() => {
69+
if (metric === 'cancellations') return CANCELLATION_METRICS
70+
if (metric === 'customers') return CUSTOMERS_ALL_METRICS
71+
return getMetricsForType(metric, {
72+
hasRecurringProducts,
73+
hasOneTimeProducts,
74+
})
75+
}, [metric, hasRecurringProducts, hasOneTimeProducts])
7676

7777
const { data } = useMetrics({
7878
startDate,
@@ -91,6 +91,8 @@ export default function ClientPage({
9191
<div className="flex flex-col gap-12">
9292
{metric === 'cancellations' ? (
9393
<CancellationsContent data={data} interval={interval} />
94+
) : metric === 'customers' ? (
95+
<CustomersContent data={data} interval={interval} />
9496
) : (
9597
<MetricGroup metricKeys={metrics} data={data} interval={interval} />
9698
)}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client'
2+
3+
import MetricChartBox from '@/components/Metrics/MetricChartBox'
4+
import { ParsedMetricsResponse } from '@/hooks/queries'
5+
import { schemas } from '@polar-sh/client'
6+
import { useRef, useState } from 'react'
7+
import { twMerge } from 'tailwind-merge'
8+
9+
const CUSTOMER_METRICS: (keyof schemas['Metrics'])[] = [
10+
'seat_customers',
11+
'new_seat_customers',
12+
'churned_seat_customers',
13+
]
14+
15+
const SEAT_METRICS: (keyof schemas['Metrics'])[] = [
16+
'seats_total',
17+
'average_seats_per_customer',
18+
'seat_utilization_rate',
19+
]
20+
21+
interface CustomersContentProps {
22+
data: ParsedMetricsResponse
23+
interval: schemas['TimeInterval']
24+
}
25+
26+
function MetricSection({
27+
title,
28+
metricKeys,
29+
data,
30+
interval,
31+
}: {
32+
title: string
33+
metricKeys: (keyof schemas['Metrics'])[]
34+
data?: ParsedMetricsResponse
35+
interval: schemas['TimeInterval']
36+
}) {
37+
const [hoveredPeriodIndex, setHoveredPeriodIndex] = useState<number | null>(
38+
null,
39+
)
40+
const activeHoverKey = useRef<string | null>(null)
41+
42+
return (
43+
<div className="flex flex-col gap-y-3">
44+
<h3 className="text-lg font-medium">{title}</h3>
45+
<div className="dark:border-polar-700 flex flex-col overflow-hidden rounded-2xl border border-gray-200">
46+
<div className="grid grid-cols-1 flex-col [clip-path:inset(1px_1px_1px_1px)] md:grid-cols-2 lg:grid-cols-3">
47+
{metricKeys.map((metricKey, index) => (
48+
<MetricChartBox
49+
key={String(metricKey)}
50+
data={data}
51+
interval={interval}
52+
metric={metricKey}
53+
height={200}
54+
chartType="line"
55+
hoveredPeriodIndex={hoveredPeriodIndex}
56+
onHoverPeriodChange={(period) => {
57+
if (period !== null) {
58+
activeHoverKey.current = String(metricKey)
59+
setHoveredPeriodIndex(period)
60+
} else if (activeHoverKey.current === String(metricKey)) {
61+
activeHoverKey.current = null
62+
setHoveredPeriodIndex(null)
63+
}
64+
}}
65+
className={twMerge(
66+
'rounded-none! bg-transparent dark:bg-transparent',
67+
index === 0 && 'lg:col-span-2',
68+
'dark:border-polar-700 border-t-0 border-r border-b border-l-0 border-gray-200 shadow-none',
69+
)}
70+
/>
71+
))}
72+
</div>
73+
</div>
74+
</div>
75+
)
76+
}
77+
78+
export function CustomersContent({ data, interval }: CustomersContentProps) {
79+
return (
80+
<div className="flex flex-col gap-y-8">
81+
<MetricSection
82+
title="Customers"
83+
metricKeys={CUSTOMER_METRICS}
84+
data={data}
85+
interval={interval}
86+
/>
87+
<MetricSection
88+
title="Seats"
89+
metricKeys={SEAT_METRICS}
90+
data={data}
91+
interval={interval}
92+
/>
93+
</div>
94+
)
95+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ export function MetricsSubNav({
7373
visible: organization.feature_settings?.revops_enabled ?? false,
7474
},
7575
{
76-
title: 'Seats',
77-
href: `${basePath}/seats`,
76+
title: 'Customers',
77+
href: `${basePath}/customers`,
7878
visible:
7979
organization.feature_settings?.seat_based_pricing_enabled ?? false,
8080
},

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const METRIC_TYPES = [
88
'checkouts',
99
'net-revenue',
1010
'costs',
11-
'seats',
11+
'customers',
1212
] as const
1313

1414
export type MetricType = (typeof METRIC_TYPES)[number]
@@ -91,10 +91,21 @@ const COST_METRICS: (keyof schemas['Metrics'])[] = [
9191
'cashflow',
9292
]
9393

94+
const CUSTOMER_METRICS: (keyof schemas['Metrics'])[] = [
95+
'seat_customers',
96+
'new_seat_customers',
97+
'churned_seat_customers',
98+
]
99+
94100
const SEAT_METRICS: (keyof schemas['Metrics'])[] = [
95101
'seats_total',
96-
'seats_claimed',
97-
'seats_pending',
102+
'average_seats_per_customer',
103+
'seat_utilization_rate',
104+
]
105+
106+
export const CUSTOMERS_ALL_METRICS: (keyof schemas['Metrics'])[] = [
107+
...CUSTOMER_METRICS,
108+
...SEAT_METRICS,
98109
]
99110

100111
export function getMetricsForType(
@@ -125,7 +136,7 @@ export function getMetricsForType(
125136
]
126137
case 'costs':
127138
return COST_METRICS
128-
case 'seats':
129-
return SEAT_METRICS
139+
case 'customers':
140+
return CUSTOMERS_ALL_METRICS
130141
}
131142
}

clients/packages/client/src/v1.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19741,12 +19741,22 @@ export interface components {
1974119741
gross_margin_percentage?: number | null
1974219742
/** Cashflow */
1974319743
cashflow?: number | null
19744-
/** Total Seats */
19744+
/** Seat Count */
1974519745
seats_total?: number | null
1974619746
/** Claimed Seats */
1974719747
seats_claimed?: number | null
1974819748
/** Pending Seats */
1974919749
seats_pending?: number | null
19750+
/** Active Customers */
19751+
seat_customers?: number | null
19752+
/** New Customers */
19753+
new_seat_customers?: number | null
19754+
/** Churned Customers */
19755+
churned_seat_customers?: number | null
19756+
/** Average Seats per Customer */
19757+
average_seats_per_customer?: number | null
19758+
/** Seat Utilization Rate */
19759+
seat_utilization_rate?: number | null
1975019760
}
1975119761
/**
1975219762
* MetricType
@@ -19815,6 +19825,11 @@ export interface components {
1981519825
seats_total?: components['schemas']['Metric'] | null
1981619826
seats_claimed?: components['schemas']['Metric'] | null
1981719827
seats_pending?: components['schemas']['Metric'] | null
19828+
seat_customers?: components['schemas']['Metric'] | null
19829+
new_seat_customers?: components['schemas']['Metric'] | null
19830+
churned_seat_customers?: components['schemas']['Metric'] | null
19831+
average_seats_per_customer?: components['schemas']['Metric'] | null
19832+
seat_utilization_rate?: components['schemas']['Metric'] | null
1981819833
}
1981919834
/**
1982019835
* MetricsIntervalLimit
@@ -19965,12 +19980,22 @@ export interface components {
1996519980
gross_margin_percentage?: number | null
1996619981
/** Cashflow */
1996719982
cashflow?: number | null
19968-
/** Total Seats */
19983+
/** Seat Count */
1996919984
seats_total?: number | null
1997019985
/** Claimed Seats */
1997119986
seats_claimed?: number | null
1997219987
/** Pending Seats */
1997319988
seats_pending?: number | null
19989+
/** Active Customers */
19990+
seat_customers?: number | null
19991+
/** New Customers */
19992+
new_seat_customers?: number | null
19993+
/** Churned Customers */
19994+
churned_seat_customers?: number | null
19995+
/** Average Seats per Customer */
19996+
average_seats_per_customer?: number | null
19997+
/** Seat Utilization Rate */
19998+
seat_utilization_rate?: number | null
1997419999
}
1997520000
/** MissingInvoiceBillingDetails */
1997620001
MissingInvoiceBillingDetails: {

server/polar/metrics/metrics.py

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from polar.enums import SubscriptionRecurringInterval
2020
from polar.kit.time_queries import TimeInterval
21-
from polar.models import Checkout, CustomerSeat, Subscription
21+
from polar.models import Checkout, CustomerSeat, Order, Subscription
2222
from polar.models.checkout import CheckoutStatus
2323

2424
from .queries import MetricQuery
@@ -450,7 +450,7 @@ def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
450450

451451
class TotalSeatsMetric(SQLMetric):
452452
slug = "seats_total"
453-
display_name = "Total Seats"
453+
display_name = "Seat Count"
454454
type = MetricType.scalar
455455
query = MetricQuery.seats
456456

@@ -513,6 +513,97 @@ def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
513513
return cumulative_last(periods, cls.slug)
514514

515515

516+
class SeatCustomersMetric(SQLMetric):
517+
slug = "seat_customers"
518+
display_name = "Active Customers"
519+
type = MetricType.scalar
520+
query = MetricQuery.seats
521+
522+
@classmethod
523+
def get_sql_expression(
524+
cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
525+
) -> ColumnElement[int]:
526+
return func.count(
527+
func.distinct(
528+
func.coalesce(
529+
CustomerSeat.customer_id,
530+
Subscription.customer_id,
531+
Order.customer_id,
532+
)
533+
)
534+
)
535+
536+
@classmethod
537+
def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
538+
return cumulative_last(periods, cls.slug)
539+
540+
541+
class NewSeatCustomersMetric(SQLMetric):
542+
slug = "new_seat_customers"
543+
display_name = "New Customers"
544+
type = MetricType.scalar
545+
query = MetricQuery.seats
546+
547+
@classmethod
548+
def get_sql_expression(
549+
cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
550+
) -> ColumnElement[int]:
551+
raise NotImplementedError("Computed directly in get_seats_cte")
552+
553+
@classmethod
554+
def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
555+
return cumulative_sum(periods, cls.slug)
556+
557+
558+
class ChurnedSeatCustomersMetric(SQLMetric):
559+
slug = "churned_seat_customers"
560+
display_name = "Churned Customers"
561+
type = MetricType.scalar
562+
query = MetricQuery.seats
563+
564+
@classmethod
565+
def get_sql_expression(
566+
cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
567+
) -> ColumnElement[int]:
568+
raise NotImplementedError("Computed directly in get_seats_cte")
569+
570+
@classmethod
571+
def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
572+
return cumulative_sum(periods, cls.slug)
573+
574+
575+
class AverageSeatsPerCustomerMetric(MetaMetric):
576+
slug = "average_seats_per_customer"
577+
display_name = "Average Seats per Customer"
578+
type = MetricType.scalar
579+
580+
@classmethod
581+
def compute_from_period(cls, period: "MetricsPeriod") -> float:
582+
total = period.seats_total or 0
583+
customers = period.seat_customers or 0
584+
return total / customers if customers > 0 else 0.0
585+
586+
@classmethod
587+
def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
588+
return float(cumulative_last(periods, cls.slug))
589+
590+
591+
class SeatUtilizationRateMetric(MetaMetric):
592+
slug = "seat_utilization_rate"
593+
display_name = "Seat Utilization Rate"
594+
type = MetricType.percentage
595+
596+
@classmethod
597+
def compute_from_period(cls, period: "MetricsPeriod") -> float:
598+
claimed = period.seats_claimed or 0
599+
total = period.seats_total or 0
600+
return claimed / total if total > 0 else 0.0
601+
602+
@classmethod
603+
def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
604+
return float(cumulative_last(periods, cls.slug))
605+
606+
516607
class LTVMetric(MetaMetric):
517608
slug = "ltv"
518609
display_name = "Lifetime Value"
@@ -913,6 +1004,9 @@ def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
9131004
TotalSeatsMetric,
9141005
ClaimedSeatsMetric,
9151006
PendingSeatsMetric,
1007+
SeatCustomersMetric,
1008+
NewSeatCustomersMetric,
1009+
ChurnedSeatCustomersMetric,
9161010
]
9171011

9181012
METRICS_POST_COMPUTE: list[type[MetaMetric]] = [
@@ -921,6 +1015,8 @@ def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
9211015
GrossMarginMetric,
9221016
GrossMarginPercentageMetric,
9231017
CashflowMetric,
1018+
AverageSeatsPerCustomerMetric,
1019+
SeatUtilizationRateMetric,
9241020
]
9251021

9261022
METRICS: list[type[Metric]] = [

0 commit comments

Comments
 (0)