|
|
|
|
@ -4,10 +4,40 @@ import {formatNumber, formatPercentage, formatQueryDate, getRangeDates} from '@t
|
|
|
|
|
import {getSymbol} from '@tryghost/admin-x-framework';
|
|
|
|
|
import {useMemo} from 'react';
|
|
|
|
|
|
|
|
|
|
// Type for direction values
|
|
|
|
|
/**
|
|
|
|
|
* DiffDirection
|
|
|
|
|
* - 用于表示数值变化方向:上升(up)、下降(down)、相同(same)
|
|
|
|
|
*/
|
|
|
|
|
export type DiffDirection = 'up' | 'down' | 'same';
|
|
|
|
|
|
|
|
|
|
// Calculate totals from member data
|
|
|
|
|
/**
|
|
|
|
|
* calculateTotals
|
|
|
|
|
* - 从 memberData 和 mrrData 计算摘要指标(总会员数、免费/付费会员、MRR)以及百分比变化与方向。
|
|
|
|
|
* - 参数:
|
|
|
|
|
* - memberData: 按日期的会员状态数组(每项包含 free/paid/comped 等)
|
|
|
|
|
* - mrrData: 按日期的 MRR 历史数组(每项包含 date/mrr/currency)
|
|
|
|
|
* - dateFrom: 选择范围的起始日期(YYYY-MM-DD 格式)
|
|
|
|
|
* - memberCountTotals: 可选的当前汇总 totals(如来自 meta,用于优先显示最新汇总)
|
|
|
|
|
*
|
|
|
|
|
* 逻辑要点:
|
|
|
|
|
* - 若没有数据,返回一组默认的 0/同向(same) 值,避免上层处理空值判断。
|
|
|
|
|
* - totalMembers 使用 memberCountTotals(如果可用)或 memberData 最后一个来计算。
|
|
|
|
|
* - percentChanges: 只有当时间序列包含 >1 个点时才计算与第一个点的百分比变化。
|
|
|
|
|
* - 对 MRR 的变化判断有特殊策略:
|
|
|
|
|
* - 识别 "from beginning" 范围(例如年初开始或更早),在该场景下若首点不在范围起始,默认首点为 0(YTD 语义)。
|
|
|
|
|
* - 对于最近范围(非 from-beginning),若缺少起点,使用范围前最近的 MRR 值(视为平滑承接)。
|
|
|
|
|
* - 若首点为 0 且当前 MRR>0,按业务逻辑显示 100% 增长。
|
|
|
|
|
*
|
|
|
|
|
* 返回值(示例):
|
|
|
|
|
* {
|
|
|
|
|
* totalMembers: number,
|
|
|
|
|
* freeMembers: number,
|
|
|
|
|
* paidMembers: number,
|
|
|
|
|
* mrr: number,
|
|
|
|
|
* percentChanges: { total, free, paid, mrr },
|
|
|
|
|
* directions: { total, free, paid, mrr }
|
|
|
|
|
* }
|
|
|
|
|
*/
|
|
|
|
|
const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem[], dateFrom: string, memberCountTotals?: {paid: number; free: number; comped: number}) => {
|
|
|
|
|
if (!memberData.length) {
|
|
|
|
|
return {
|
|
|
|
|
@ -30,18 +60,18 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use current totals from API meta if available (like Ember), otherwise use latest time series data
|
|
|
|
|
// 使用后端 meta 的 totals(如果存在),否则从时间序列取最后一个点作为当前值
|
|
|
|
|
const currentTotals = memberCountTotals || memberData[memberData.length - 1];
|
|
|
|
|
const latest = memberData.length > 0 ? memberData[memberData.length - 1] : {free: 0, paid: 0, comped: 0};
|
|
|
|
|
|
|
|
|
|
const latestMrr = mrrData.length > 0 ? mrrData[mrrData.length - 1] : {mrr: 0};
|
|
|
|
|
|
|
|
|
|
// Calculate total members using current totals (like Ember dashboard)
|
|
|
|
|
// 计算总会员数(免费 + 付费 + comped)
|
|
|
|
|
const totalMembers = currentTotals.free + currentTotals.paid + currentTotals.comped;
|
|
|
|
|
|
|
|
|
|
const totalMrr = latestMrr.mrr;
|
|
|
|
|
|
|
|
|
|
// Calculate percentage changes if we have enough data
|
|
|
|
|
// 默认百分比与方向
|
|
|
|
|
const percentChanges = {
|
|
|
|
|
total: '0%',
|
|
|
|
|
free: '0%',
|
|
|
|
|
@ -56,8 +86,8 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
mrr: 'same' as DiffDirection
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 只有在成员时间序列存在 >=2 个点时,才计算与第一天的百分比变化
|
|
|
|
|
if (memberData.length > 1) {
|
|
|
|
|
// Get first day in range
|
|
|
|
|
const first = memberData[0];
|
|
|
|
|
const firstTotal = first.free + first.paid + first.comped;
|
|
|
|
|
|
|
|
|
|
@ -83,43 +113,43 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MRR 的百分比变化需要更复杂的边界处理,考虑到 YTD 或缺失数据点情况
|
|
|
|
|
if (mrrData.length > 1) {
|
|
|
|
|
// Find the first ACTUAL data point within the selected date range (not synthetic boundary points)
|
|
|
|
|
// 选择范围内第一个真正的点(实际数据点,而非人为添加的边界点)
|
|
|
|
|
const actualStartDate = moment(dateFrom).format('YYYY-MM-DD');
|
|
|
|
|
const firstActualPoint = mrrData.find(point => moment(point.date).isSameOrAfter(actualStartDate));
|
|
|
|
|
|
|
|
|
|
// Check if this is a "from beginning" range (like YTD) vs a recent range
|
|
|
|
|
// 判断是否为从“起始/年初”开始的范围(例如 YTD),这会将缺失起点视作 0
|
|
|
|
|
const isFromBeginningRange = moment(dateFrom).isSame(moment().startOf('year'), 'day') ||
|
|
|
|
|
moment(dateFrom).year() < moment().year();
|
|
|
|
|
|
|
|
|
|
let firstMrr = 0;
|
|
|
|
|
|
|
|
|
|
if (firstActualPoint) {
|
|
|
|
|
// Check if the first actual point is exactly at the start date
|
|
|
|
|
if (moment(firstActualPoint.date).isSame(actualStartDate, 'day')) {
|
|
|
|
|
// 起点恰好落在范围开始
|
|
|
|
|
firstMrr = firstActualPoint.mrr;
|
|
|
|
|
} else {
|
|
|
|
|
// First actual point is later than start date
|
|
|
|
|
// 起点在范围之后
|
|
|
|
|
if (isFromBeginningRange) {
|
|
|
|
|
// For YTD/beginning ranges, assume started from 0
|
|
|
|
|
// 对于 YTD 等从年初/更早开始的范围,假设从 0 开始增长
|
|
|
|
|
firstMrr = 0;
|
|
|
|
|
} else {
|
|
|
|
|
// For recent ranges, use the most recent MRR before the range
|
|
|
|
|
// This should be the same as current MRR (flat line scenario)
|
|
|
|
|
// 对于近期范围,采用范围外最近的 MRR 值(平滑承接)
|
|
|
|
|
firstMrr = totalMrr;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (isFromBeginningRange) {
|
|
|
|
|
// No data points in range, and it's a from-beginning range
|
|
|
|
|
// 范围内无数据且属于从起始范围 -> 起点为 0
|
|
|
|
|
firstMrr = 0;
|
|
|
|
|
} else {
|
|
|
|
|
// No data points in recent range, carry forward current MRR
|
|
|
|
|
// 范围内无数据且为近期范围 -> 使用当前 MRR(没有变化)
|
|
|
|
|
firstMrr = totalMrr;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (firstMrr >= 0) { // Allow 0 as a valid starting point
|
|
|
|
|
if (firstMrr >= 0) { // 允许 0 作为有效起点
|
|
|
|
|
const mrrChange = firstMrr === 0
|
|
|
|
|
? (totalMrr > 0 ? 100 : 0) // If starting from 0, any positive value is 100% increase
|
|
|
|
|
? (totalMrr > 0 ? 100 : 0) // 起点0且有增长 -> 视作 100% 增长
|
|
|
|
|
: ((totalMrr - firstMrr) / firstMrr) * 100;
|
|
|
|
|
|
|
|
|
|
percentChanges.mrr = formatPercentage(mrrChange / 100);
|
|
|
|
|
@ -137,15 +167,25 @@ const calculateTotals = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Format chart data
|
|
|
|
|
/**
|
|
|
|
|
* formatChartData
|
|
|
|
|
* - 将 memberData 与 mrrData 合并成用于曲线图的日期序列:
|
|
|
|
|
* - 合并所有出现过的日期(member 与 mrr 的并集)
|
|
|
|
|
* - 对缺失日期使用最近已知值向前填充(last-known 值),以保证图表线连续
|
|
|
|
|
* - 输出每个日期的 value(总会员)、free、paid、comped、mrr、formattedValue、label 等字段
|
|
|
|
|
*
|
|
|
|
|
* 说明:
|
|
|
|
|
* - 输入数据会先按日期升序排序,返回的数组也按升序排列(便于前端直接用于图表)
|
|
|
|
|
*/
|
|
|
|
|
const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem[]) => {
|
|
|
|
|
// Ensure data is sorted by date
|
|
|
|
|
// 确保时间序列按日期升序
|
|
|
|
|
const sortedMemberData = [...memberData].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
|
const sortedMrrData = [...mrrData].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
|
|
|
|
|
|
const memberDates = sortedMemberData.map(item => item.date);
|
|
|
|
|
const mrrDates = sortedMrrData.map(item => item.date);
|
|
|
|
|
|
|
|
|
|
// 合并去重后的所有日期并按时间排序
|
|
|
|
|
const allDates = [...new Set([...memberDates, ...mrrDates])].sort((a, b) => new Date(a).getTime() - new Date(b).getTime());
|
|
|
|
|
|
|
|
|
|
let lastMemberItem: MemberStatusItem | null = null;
|
|
|
|
|
@ -155,6 +195,7 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
const mrrMap = new Map(sortedMrrData.map(item => [item.date, item]));
|
|
|
|
|
|
|
|
|
|
return allDates.map((date) => {
|
|
|
|
|
// 若该日期存在 member/mrr 项,则更新 last-known
|
|
|
|
|
const currentMemberItem = memberMap.get(date);
|
|
|
|
|
if (currentMemberItem) {
|
|
|
|
|
lastMemberItem = currentMemberItem;
|
|
|
|
|
@ -165,6 +206,7 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
lastMrrItem = currentMrrItem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 使用最近已知的值向前填充缺失数据
|
|
|
|
|
const free = lastMemberItem?.free ?? 0;
|
|
|
|
|
const paid = lastMemberItem?.paid ?? 0;
|
|
|
|
|
const comped = lastMemberItem?.comped ?? 0;
|
|
|
|
|
@ -184,18 +226,40 @@ const formatChartData = (memberData: MemberStatusItem[], mrrData: MrrHistoryItem
|
|
|
|
|
paid_subscribed: paidSubscribed,
|
|
|
|
|
paid_canceled: paidCanceled,
|
|
|
|
|
formattedValue: formatNumber(value),
|
|
|
|
|
label: 'Total members' // Consider if label needs update based on data type?
|
|
|
|
|
label: 'Total members' // 注意:如需根据视图切换 label,可在上层处理
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* useGrowthStats(range)
|
|
|
|
|
* - 入口 Hook:接受一个 range(天数,例如 1, 7, 30 等)
|
|
|
|
|
* - 返回:
|
|
|
|
|
* - isLoading: 是否仍在加载底层 API
|
|
|
|
|
* - memberData: 处理后的 member 时间序列(对单日范围做了特殊处理保证至少 2 个点)
|
|
|
|
|
* - mrrData: 选择货币后的 MRR 时间序列(已填补起/终点)
|
|
|
|
|
* - dateFrom / endDate: 查询使用的范围日期字符串
|
|
|
|
|
* - totals: calculateTotals 的结果(汇总数字 + 百分比 + 方向)
|
|
|
|
|
* - chartData: formatChartData 的结果,直接可用于图表
|
|
|
|
|
* - subscriptionData: 合并后的订阅事件时间序列(signups/cancellations)
|
|
|
|
|
* - selectedCurrency / currencySymbol: 货币选择与符号
|
|
|
|
|
*
|
|
|
|
|
* 关键实现细节:
|
|
|
|
|
* - 使用 getRangeDates(range) 获得时区感知的 startDate / endDate
|
|
|
|
|
* - 对于 range===1(今日):
|
|
|
|
|
* - member 数据请求需包含昨天的数据(以便构造两点:昨日->今日),从而在图表上显示变化斜率
|
|
|
|
|
* - mrrData 的处理:
|
|
|
|
|
* - 从 meta.totals 选择 MRR 最大的 currency,然后只保留该币种的数据
|
|
|
|
|
* - 确保时间序列在 dateFrom 与 dateTo 有边界点:若缺失则从范围外取最近值或复制最后值到范围结束
|
|
|
|
|
* - subscriptionData:
|
|
|
|
|
* - 合并按日期的 signups/cancellations(reduce 合并),并过滤到请求范围内
|
|
|
|
|
*/
|
|
|
|
|
export const useGrowthStats = (range: number) => {
|
|
|
|
|
// Calculate date range using Shade's timezone-aware getRangeDates
|
|
|
|
|
// 使用 Shade 提供的时区感知工具计算起止日期
|
|
|
|
|
const {startDate, endDate} = useMemo(() => getRangeDates(range), [range]);
|
|
|
|
|
const dateFrom = formatQueryDate(startDate);
|
|
|
|
|
|
|
|
|
|
// Fetch member count history from API
|
|
|
|
|
// For single day ranges, we need at least 2 days of data to show a proper delta
|
|
|
|
|
// memberData 请求:对于单日需要从昨天开始以便生成两个点
|
|
|
|
|
const memberDataStartDate = range === 1 ? moment(dateFrom).subtract(1, 'day').format('YYYY-MM-DD') : dateFrom;
|
|
|
|
|
|
|
|
|
|
const {data: memberCountResponse, isLoading: isMemberCountLoading} = useMemberCountHistory({
|
|
|
|
|
@ -204,35 +268,35 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// MRR 历史(全币种)——后续会基于 meta 选择最佳币种
|
|
|
|
|
const {data: mrrHistoryResponse, isLoading: isMrrLoading} = useMrrHistory();
|
|
|
|
|
|
|
|
|
|
// Fetch subscription stats for real subscription events
|
|
|
|
|
// 订阅事件(用于真实的 signups / cancellations)
|
|
|
|
|
const {data: subscriptionStatsResponse, isLoading: isSubscriptionLoading} = useSubscriptionStats();
|
|
|
|
|
|
|
|
|
|
// Process member data with stable reference
|
|
|
|
|
/**
|
|
|
|
|
* memberData memo:
|
|
|
|
|
* - 兼容后端返回 stats 在 response.stats 或直接返回数组的情况
|
|
|
|
|
* - 对 range===1 执行特殊转换:把昨天的 EOD 视作 startOfToday,今天的 EOD 视作 startOfTomorrow,
|
|
|
|
|
* 从而在图表上形成两个点(便于展示今日变化)
|
|
|
|
|
*/
|
|
|
|
|
const memberData = useMemo(() => {
|
|
|
|
|
let rawData: MemberStatusItem[] = [];
|
|
|
|
|
|
|
|
|
|
// Check the structure of the response and extract data
|
|
|
|
|
if (memberCountResponse?.stats) {
|
|
|
|
|
rawData = memberCountResponse.stats;
|
|
|
|
|
} else if (Array.isArray(memberCountResponse)) {
|
|
|
|
|
// If response is directly an array
|
|
|
|
|
rawData = memberCountResponse;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// For single day (Today), ensure we have two data points for a proper line
|
|
|
|
|
if (range === 1 && rawData.length >= 2) {
|
|
|
|
|
// We should have yesterday's data and today's data
|
|
|
|
|
const yesterdayData = rawData[rawData.length - 2]; // Yesterday's EOD counts
|
|
|
|
|
const todayData = rawData[rawData.length - 1]; // Today's EOD counts
|
|
|
|
|
const yesterdayData = rawData[rawData.length - 2]; // 昨日 EOD
|
|
|
|
|
const todayData = rawData[rawData.length - 1]; // 今日 EOD
|
|
|
|
|
|
|
|
|
|
const startOfToday = moment(dateFrom).format('YYYY-MM-DD'); // 6/26
|
|
|
|
|
const startOfTomorrow = moment(dateFrom).add(1, 'day').format('YYYY-MM-DD'); // 6/27
|
|
|
|
|
const startOfToday = moment(dateFrom).format('YYYY-MM-DD');
|
|
|
|
|
const startOfTomorrow = moment(dateFrom).add(1, 'day').format('YYYY-MM-DD');
|
|
|
|
|
|
|
|
|
|
// Create two data points:
|
|
|
|
|
// 1. Yesterday's EOD count attributed to start of today (6/26)
|
|
|
|
|
// 2. Today's EOD count attributed to start of tomorrow (6/27)
|
|
|
|
|
// 构造两个点:昨天的数据点标记为 startOfToday,今天的数据点标记为 startOfTomorrow
|
|
|
|
|
const startPoint = {
|
|
|
|
|
...yesterdayData,
|
|
|
|
|
date: startOfToday
|
|
|
|
|
@ -249,13 +313,21 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
return rawData;
|
|
|
|
|
}, [memberCountResponse, range, dateFrom]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* mrrData memo:
|
|
|
|
|
* - 选择 meta.totals 中总量最大的 currency,过滤出该 currency 的数据
|
|
|
|
|
* - 过滤到 dateFrom 之后的点,并确保在范围开始(dateFrom)和结束(endDate/endOfDay)有边界点:
|
|
|
|
|
* - 若起点缺失:尝试从范围外取最近的点并把它的值设在 dateFrom
|
|
|
|
|
* - 若终点缺失:把最近的值复制到范围结束,保证图表不会在末尾断开
|
|
|
|
|
* - 对单日 range 使用 end-of-today 的处理以保证时间一致性
|
|
|
|
|
*/
|
|
|
|
|
const {mrrData, selectedCurrency} = useMemo(() => {
|
|
|
|
|
const dateFromMoment = moment(dateFrom);
|
|
|
|
|
// For "Today" range (1 day), use end of today to match visitor data behavior
|
|
|
|
|
// 单日范围时,使用当日结束时间作为检查边界
|
|
|
|
|
const dateToMoment = range === 1 ? moment().endOf('day') : moment().startOf('day');
|
|
|
|
|
|
|
|
|
|
if (mrrHistoryResponse?.stats && mrrHistoryResponse?.meta?.totals) {
|
|
|
|
|
// Select the currency with the highest total MRR value (same logic as Dashboard)
|
|
|
|
|
// 从 meta.totals 中选取 mrr 最大的 currency(与 Dashboard 一致的选择逻辑)
|
|
|
|
|
const totals = mrrHistoryResponse.meta.totals;
|
|
|
|
|
let currentMax = totals[0];
|
|
|
|
|
if (!currentMax) {
|
|
|
|
|
@ -270,17 +342,19 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
|
|
|
|
|
const useCurrency = currentMax.currency;
|
|
|
|
|
|
|
|
|
|
// Filter MRR data to only include the selected currency
|
|
|
|
|
// 筛选出所选货币的数据
|
|
|
|
|
const currencyFilteredData = mrrHistoryResponse.stats.filter(d => d.currency === useCurrency);
|
|
|
|
|
|
|
|
|
|
// 只保留在 dateFrom 及之后的点(范围内部数据)
|
|
|
|
|
const filteredData = currencyFilteredData.filter((item) => {
|
|
|
|
|
return moment(item.date).isSameOrAfter(dateFromMoment);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// allData 用于查找范围外最近的点(向前回填)
|
|
|
|
|
const allData = [...currencyFilteredData].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
|
const result = [...filteredData];
|
|
|
|
|
|
|
|
|
|
// Always ensure we have a data point at the start of the range
|
|
|
|
|
// 确保起点存在:若缺失则尝试取范围外最近的点并把它插入为 dateFrom
|
|
|
|
|
const hasStartPoint = result.some(item => moment(item.date).isSame(dateFromMoment, 'day'));
|
|
|
|
|
if (!hasStartPoint) {
|
|
|
|
|
const mostRecentBeforeRange = allData.find((item) => {
|
|
|
|
|
@ -295,11 +369,10 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Always ensure we have a data point at the end of the range
|
|
|
|
|
// 确保终点存在:若缺失则把当前 result 中最近的值复制为结束点
|
|
|
|
|
const endDateToCheck = range === 1 ? moment().startOf('day') : dateToMoment;
|
|
|
|
|
const hasEndPoint = result.some(item => moment(item.date).isSame(endDateToCheck, 'day'));
|
|
|
|
|
if (!hasEndPoint && result.length > 0) {
|
|
|
|
|
// Use the most recent value in our result set
|
|
|
|
|
const sortedResult = [...result].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
|
const mostRecentValue = sortedResult[0];
|
|
|
|
|
|
|
|
|
|
@ -309,6 +382,7 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 最终按时间升序返回
|
|
|
|
|
const finalResult = result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
|
|
|
|
|
|
return {mrrData: finalResult, selectedCurrency: useCurrency};
|
|
|
|
|
@ -316,26 +390,32 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
return {mrrData: [], selectedCurrency: 'usd'};
|
|
|
|
|
}, [mrrHistoryResponse, dateFrom, range]);
|
|
|
|
|
|
|
|
|
|
// Calculate totals
|
|
|
|
|
// totalsData:计算摘要数字(使用 calculateTotals)
|
|
|
|
|
const totalsData = useMemo(() => calculateTotals(memberData, mrrData, dateFrom, memberCountResponse?.meta?.totals), [memberData, mrrData, dateFrom, memberCountResponse?.meta?.totals]);
|
|
|
|
|
|
|
|
|
|
// Format chart data
|
|
|
|
|
// chartData:将时间序列格式化为图表友好的连续数据(formatChartData)
|
|
|
|
|
const chartData = useMemo(() => formatChartData(memberData, mrrData), [memberData, mrrData]);
|
|
|
|
|
|
|
|
|
|
// Get currency symbol
|
|
|
|
|
// 货币符号(用于显示 MRR 时的单位)
|
|
|
|
|
const currencySymbol = useMemo(() => {
|
|
|
|
|
return getSymbol(selectedCurrency);
|
|
|
|
|
}, [selectedCurrency]);
|
|
|
|
|
|
|
|
|
|
// 综合加载状态(任一基础请求在加载则返回 true)
|
|
|
|
|
const isLoading = useMemo(() => isMemberCountLoading || isMrrLoading || isSubscriptionLoading, [isMemberCountLoading, isMrrLoading, isSubscriptionLoading]);
|
|
|
|
|
|
|
|
|
|
// Process subscription data for real subscription events (like Ember dashboard)
|
|
|
|
|
/**
|
|
|
|
|
* subscriptionData:
|
|
|
|
|
* - 将 subscriptionStatsResponse.stats 按日期合并(累加 signups 与 cancellations)
|
|
|
|
|
* - 结果按时间升序排序,并过滤到请求范围 [dateFrom, endDate]
|
|
|
|
|
* - 主要用于显示真实的订阅事件(区别于累积会员数)
|
|
|
|
|
*/
|
|
|
|
|
const subscriptionData = useMemo(() => {
|
|
|
|
|
if (!subscriptionStatsResponse?.stats) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Merge subscription stats by date (like Ember's mergeStatsByDate)
|
|
|
|
|
// 按日期合并(merge by date)
|
|
|
|
|
const mergedByDate = subscriptionStatsResponse.stats.reduce((acc, current) => {
|
|
|
|
|
const dateKey = current.date;
|
|
|
|
|
|
|
|
|
|
@ -353,11 +433,11 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
return acc;
|
|
|
|
|
}, {} as Record<string, {date: string; signups: number; cancellations: number}>);
|
|
|
|
|
|
|
|
|
|
// Convert to array and sort by date
|
|
|
|
|
// 转数组并按日期排序
|
|
|
|
|
const subscriptionArray = Object.values(mergedByDate).sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Filter to requested date range
|
|
|
|
|
// 过滤到请求范围
|
|
|
|
|
const dateFromMoment = moment(dateFrom);
|
|
|
|
|
const dateToMoment = moment(endDate);
|
|
|
|
|
return subscriptionArray.filter((item) => {
|
|
|
|
|
@ -366,6 +446,7 @@ export const useGrowthStats = (range: number) => {
|
|
|
|
|
});
|
|
|
|
|
}, [subscriptionStatsResponse, dateFrom, endDate]);
|
|
|
|
|
|
|
|
|
|
// 最终返回:供组件/页面直接消费的所有字段
|
|
|
|
|
return {
|
|
|
|
|
isLoading,
|
|
|
|
|
memberData,
|
|
|
|
|
|