Skip to content

Commit 1ec2509

Browse files
authored
Wired up member counts to stats growth page (#23139)
ref https://linear.app/ghost/issue/PROD-658/ The growth tab currently has fixture data for demo purposes. This wires up the member counts endpoint used by the Admin Dashboard. We had to make a small adjustment to allow for a start date, as it was previously always looking back 91 days. We will probably want to make some additional adjustments to this down the road.
1 parent 9114c98 commit 1ec2509

File tree

7 files changed

+394
-224
lines changed

7 files changed

+394
-224
lines changed

apps/admin-x-framework/src/api/stats.ts

+26
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,37 @@ export type TopContentResponseType = {
1515
meta: Meta;
1616
}
1717

18+
export type MemberStatusItem = {
19+
date: string;
20+
paid: number;
21+
free: number;
22+
comped: number;
23+
paid_subscribed: number;
24+
paid_canceled: number;
25+
}
26+
27+
export type MemberCountHistoryResponseType = {
28+
stats: MemberStatusItem[];
29+
meta: {
30+
totals: {
31+
paid: number;
32+
free: number;
33+
comped: number;
34+
}
35+
};
36+
}
37+
1838
// Requests
1939

2040
const dataType = 'TopContentResponseType';
41+
const memberCountHistoryDataType = 'MemberCountHistoryResponseType';
2142

2243
export const useTopContent = createQuery<TopContentResponseType>({
2344
dataType,
2445
path: '/stats/top-content/'
46+
});
47+
48+
export const useMemberCountHistory = createQuery<MemberCountHistoryResponseType>({
49+
dataType: memberCountHistoryDataType,
50+
path: '/stats/member_count/'
2551
});
+153
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import moment from 'moment';
2+
import {MemberStatusItem, useMemberCountHistory} from '@tryghost/admin-x-framework/api/stats';
3+
import {formatNumber} from '@tryghost/shade';
4+
import {useMemo} from 'react';
5+
6+
// Type for direction values
7+
export type DiffDirection = 'up' | 'down' | 'same';
8+
9+
// Helper function to convert range to date parameters
10+
export const getRangeDates = (rangeInDays: number) => {
11+
// Always use UTC to stay aligned with the backend’s date arithmetic
12+
const endDate = moment.utc().format('YYYY-MM-DD');
13+
let dateFrom;
14+
15+
if (rangeInDays === 1) {
16+
// Today
17+
dateFrom = endDate;
18+
} else if (rangeInDays === 1000) {
19+
// All time - use a far past date
20+
dateFrom = '2010-01-01';
21+
} else {
22+
// Specific range
23+
// Guard against invalid ranges
24+
const safeRange = Math.max(1, rangeInDays);
25+
dateFrom = moment.utc().subtract(safeRange - 1, 'days').format('YYYY-MM-DD');
26+
}
27+
28+
return {dateFrom, endDate};
29+
};
30+
31+
// Calculate totals from member data
32+
const calculateTotals = (memberData: MemberStatusItem[]) => {
33+
if (!memberData.length) {
34+
return {
35+
totalMembers: 0,
36+
freeMembers: 0,
37+
paidMembers: 0,
38+
percentChanges: {
39+
total: '0%',
40+
free: '0%',
41+
paid: '0%'
42+
},
43+
directions: {
44+
total: 'same' as DiffDirection,
45+
free: 'same' as DiffDirection,
46+
paid: 'same' as DiffDirection
47+
}
48+
};
49+
}
50+
51+
// Get latest values
52+
const latest = memberData[memberData.length - 1];
53+
54+
// Calculate total members
55+
const totalMembers = latest.free + latest.paid + latest.comped;
56+
57+
// Calculate percentage changes if we have enough data
58+
const percentChanges = {
59+
total: '0%',
60+
free: '0%',
61+
paid: '0%'
62+
};
63+
64+
const directions = {
65+
total: 'same' as DiffDirection,
66+
free: 'same' as DiffDirection,
67+
paid: 'same' as DiffDirection
68+
};
69+
70+
if (memberData.length > 1) {
71+
// Get first day in range
72+
const first = memberData[0];
73+
const firstTotal = first.free + first.paid + first.comped;
74+
75+
if (firstTotal > 0) {
76+
const totalChange = ((totalMembers - firstTotal) / firstTotal) * 100;
77+
percentChanges.total = `${Math.abs(totalChange).toFixed(1)}%`;
78+
directions.total = totalChange > 0 ? 'up' : totalChange < 0 ? 'down' : 'same';
79+
}
80+
81+
if (first.free > 0) {
82+
const freeChange = ((latest.free - first.free) / first.free) * 100;
83+
percentChanges.free = `${Math.abs(freeChange).toFixed(1)}%`;
84+
directions.free = freeChange > 0 ? 'up' : freeChange < 0 ? 'down' : 'same';
85+
}
86+
87+
if (first.paid > 0) {
88+
const paidChange = ((latest.paid - first.paid) / first.paid) * 100;
89+
percentChanges.paid = `${Math.abs(paidChange).toFixed(1)}%`;
90+
directions.paid = paidChange > 0 ? 'up' : paidChange < 0 ? 'down' : 'same';
91+
}
92+
}
93+
94+
return {
95+
totalMembers,
96+
freeMembers: latest.free,
97+
paidMembers: latest.paid,
98+
percentChanges,
99+
directions
100+
};
101+
};
102+
103+
// Format chart data
104+
const formatChartData = (memberData: MemberStatusItem[]) => {
105+
return memberData.map(item => ({
106+
date: item.date,
107+
value: item.free + item.paid + item.comped,
108+
free: item.free,
109+
paid: item.paid,
110+
comped: item.comped,
111+
formattedValue: formatNumber(item.free + item.paid + item.comped),
112+
label: 'Total members'
113+
}));
114+
};
115+
116+
export const useGrowthStats = (range: number) => {
117+
// Calculate date range
118+
const {dateFrom, endDate} = useMemo(() => getRangeDates(range), [range]);
119+
120+
// Fetch member count history from API
121+
const {data: memberCountResponse, isLoading} = useMemberCountHistory({
122+
searchParams: {
123+
date_from: dateFrom
124+
}
125+
});
126+
127+
// Process member data with stable reference
128+
const memberData = useMemo(() => {
129+
// Check the structure of the response and extract data
130+
if (memberCountResponse?.stats) {
131+
return memberCountResponse.stats;
132+
} else if (Array.isArray(memberCountResponse)) {
133+
// If response is directly an array
134+
return memberCountResponse;
135+
}
136+
return [];
137+
}, [memberCountResponse]);
138+
139+
// Calculate totals
140+
const totalsData = useMemo(() => calculateTotals(memberData), [memberData]);
141+
142+
// Format chart data
143+
const chartData = useMemo(() => formatChartData(memberData), [memberData]);
144+
145+
return {
146+
isLoading,
147+
memberData,
148+
dateFrom,
149+
endDate,
150+
totals: totalsData,
151+
chartData
152+
};
153+
};

0 commit comments

Comments
 (0)