Correct product analytics cohorts and reporting
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

This commit is contained in:
Rocky
2026-08-21 17:50:35 +08:00
parent b25f5ae6e9
commit edd0d9feca
31 changed files with 1134 additions and 262 deletions
+21
View File
@@ -187,6 +187,10 @@ export interface TrendPoint {
}
export interface Overview {
period: {
from: string;
until: string;
};
totalUsers: number;
activeUsers: number;
newUsers: number;
@@ -210,6 +214,10 @@ export interface ReferralRankingItem {
}
export interface ReferralOverview {
period: {
from: string;
until: string;
};
pendingBindings: number;
ineligibleBindings: number;
funnel: FunnelStep[];
@@ -244,6 +252,12 @@ export interface AnalyticsFeatureUsage {
successes: number;
}
export interface AnalyticsLatencyBucket {
bucket: string;
successful: number;
failed: number;
}
export interface ProductAnalyticsOverview {
period: {
from: string;
@@ -282,6 +296,8 @@ export interface ProductAnalyticsOverview {
conversion7d: AnalyticsRate;
conversion30d: AnalyticsRate;
repeatPurchaseRate: AnalyticsRate;
purchaseFunnel: FunnelStep[];
cancelledUsers: number;
};
growthFunnel: FunnelStep[];
retention: AnalyticsCohort[];
@@ -305,11 +321,16 @@ export interface ProductAnalyticsOverview {
mixedLanguageSessions: number;
otherOnlySessions: number;
};
referralSignals: {
shared: number;
opened: number;
};
referralFunnel: FunnelStep[];
guardrails: {
clientAiSuccessRate: AnalyticsRate;
managedSuccessRate: AnalyticsRate;
creditBlockedUsers: number;
latencyBuckets: AnalyticsLatencyBucket[];
};
}
@@ -6,8 +6,8 @@ type ChartTone = "primary" | "violet" | "success" | "warning";
export interface ComparisonBarItem {
id?: string;
label: string;
value: number;
secondaryValue?: number;
value: number | null;
secondaryValue?: number | null;
hint?: string;
}
@@ -33,7 +33,7 @@ export function ComparisonBarChart({
}
const maximum = Math.max(
...items.flatMap((item) => [item.value, item.secondaryValue ?? 0]),
...items.flatMap((item) => [item.value ?? 0, item.secondaryValue ?? 0]),
1,
);
const legend = [
@@ -56,15 +56,17 @@ export function ComparisonBarChart({
maximum={maximum}
tone={primaryTone}
value={item.value}
valueLabel={valueFormatter(item.value)}
valueLabel={item.value == null ? "—" : valueFormatter(item.value)}
/>
{secondaryLabel != null && item.secondaryValue != null ? (
{secondaryLabel != null && item.secondaryValue !== undefined ? (
<Bar
label={`${item.label} ${secondaryLabel}`}
maximum={maximum}
tone={secondaryTone}
value={item.secondaryValue}
valueLabel={valueFormatter(item.secondaryValue)}
valueLabel={
item.secondaryValue == null ? "—" : valueFormatter(item.secondaryValue)
}
/>
) : null}
</div>
@@ -84,7 +86,7 @@ function Bar({
label: string;
maximum: number;
tone: ChartTone;
value: number;
value: number | null;
valueLabel: string;
}) {
return (
@@ -92,8 +94,8 @@ function Bar({
<progress
className={`bar-progress bar-progress--${tone} block h-2.5 min-w-0 flex-1 overflow-hidden rounded-full`}
max={maximum}
value={value}
aria-label={`${label}${valueLabel}`}
value={value ?? 0}
aria-label={value == null ? `${label}:暂无数据` : `${label}${valueLabel}`}
/>
<strong className="w-16 shrink-0 text-right text-xs font-semibold tabular-nums text-foreground">
{valueLabel}
@@ -12,7 +12,7 @@ export function FunnelChart({
return <p className="p-8 text-center text-sm text-muted">{emptyText}</p>;
}
const maximum = Math.max(steps[0]?.count ?? 0, ...steps.map((step) => step.count), 1);
const maximum = Math.max(steps[0]?.count ?? 0, 1);
return (
<div className="space-y-5 p-5 sm:p-6">
@@ -7,11 +7,11 @@ export function RadialMetric({
tone = "primary",
}: {
label: string;
percent: number;
percent: number | null;
detail?: string;
tone?: RadialTone;
}) {
const value = Math.min(Math.max(percent, 0), 100);
const value = percent == null ? null : Math.min(Math.max(percent, 0), 100);
return (
<div className="flex items-center gap-5">
@@ -19,7 +19,7 @@ export function RadialMetric({
className="size-28 shrink-0"
viewBox="0 0 120 120"
role="img"
aria-label={`${label}${value.toFixed(1)}%`}
aria-label={value == null ? `${label}:暂无数据` : `${label}${value.toFixed(1)}%`}
>
<circle className="radial-track" cx="60" cy="60" r="48" pathLength="100" />
<circle
@@ -28,11 +28,11 @@ export function RadialMetric({
cy="60"
r="48"
pathLength="100"
strokeDasharray={`${value} ${100 - value}`}
strokeDasharray={`${value ?? 0} ${100 - (value ?? 0)}`}
transform="rotate(-90 60 60)"
/>
<text className="radial-label" x="60" y="65" textAnchor="middle">
{Math.round(value)}%
{value == null ? "—" : `${Math.round(value)}%`}
</text>
</svg>
<div>
@@ -0,0 +1,20 @@
export function PeriodCaption({
from,
until,
}: {
from: string;
until: string;
}) {
return (
<p className="text-xs text-muted" aria-label="实际统计周期">
{utcLabel(from)} {utcLabel(until)}UTC
</p>
);
}
function utcLabel(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toISOString().replace("T", " ").slice(0, 16);
}
@@ -23,6 +23,7 @@ import { ComparisonBarChart } from "../../components/charts/comparison-bar-chart
import { FunnelChart } from "../../components/charts/funnel-chart";
import { FilterControl, ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format";
@@ -110,7 +111,12 @@ export function AnalyticsPage() {
eyebrow="CEO Dashboard"
title="产品增长与留存"
description="围绕成功使用 AI 的核心价值事件,观察增长质量、留存、消耗与付费。"
actions={<RangeControl value={range} onChange={setRange} />}
actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="北极星指标">
@@ -151,7 +157,7 @@ export function AnalyticsPage() {
<Card className="overflow-hidden">
<SectionHeader
title="增长激活漏斗"
description="首次启动到首次 AI 成功,显示逐步及累计转化"
description="同一批已完成 24 小时观察的新安装,所有步骤必须在首次启动后 24 小时内完成"
icon={Target}
/>
<FunnelChart steps={data.growthFunnel} />
@@ -241,7 +247,7 @@ export function AnalyticsPage() {
<Card className="overflow-hidden">
<SectionHeader
title="活跃与消耗"
description="托管调用以服务端结算为准"
description="DAU / WAU / MAU 为截至统计截止时刻的滚动 1 / 7 / 30 日 AI 价值活跃用户"
icon={Activity}
/>
<ComparisonBarChart
@@ -335,20 +341,24 @@ export function AnalyticsPage() {
<section className="grid gap-6 xl:grid-cols-3">
<Card className="overflow-hidden">
<SectionHeader title="付费转化" description="StoreKit 已验证交易" icon={BadgeDollarSign} />
<SectionHeader
title="付费转化"
description="所选周期内完成 7 / 30 天观察窗的注册 cohort;购买以 StoreKit 验证为准"
icon={BadgeDollarSign}
/>
<ComparisonBarChart
items={[
{
label: "7 天免费转付费",
value: data.monetization.conversion7d.percent ?? 0,
value: data.monetization.conversion7d.percent ?? null,
},
{
label: "30 天免费转付费",
value: data.monetization.conversion30d.percent ?? 0,
value: data.monetization.conversion30d.percent ?? null,
},
{
label: "复购率",
value: data.monetization.repeatPurchaseRate.percent ?? 0,
value: data.monetization.repeatPurchaseRate.percent ?? null,
},
]}
primaryLabel="转化率"
@@ -367,9 +377,15 @@ export function AnalyticsPage() {
<Card className="overflow-hidden">
<SectionHeader
title="推荐增长漏斗"
description="分享打开、绑定、激活与奖励"
description="严格绑定 cohort分享打开仅作为独立方向信号"
icon={Target}
/>
<MetricRows
rows={[
["客户端分享信号", formatNumber(data.referralSignals.shared)],
["邀请打开信号", formatNumber(data.referralSignals.opened)],
]}
/>
<FunnelChart steps={data.referralFunnel} />
</Card>
@@ -379,11 +395,11 @@ export function AnalyticsPage() {
items={[
{
label: "客户端 AI",
value: data.guardrails.clientAiSuccessRate.percent ?? 0,
value: data.guardrails.clientAiSuccessRate.percent ?? null,
},
{
label: "托管请求",
value: data.guardrails.managedSuccessRate.percent ?? 0,
value: data.guardrails.managedSuccessRate.percent ?? null,
},
]}
primaryLabel="成功率"
@@ -399,6 +415,44 @@ export function AnalyticsPage() {
</Card>
</section>
<section className="grid gap-6 xl:grid-cols-2">
<Card className="overflow-hidden">
<SectionHeader
title="购买意向漏斗"
description="同一安装依次浏览、发起购买,并由 StoreKit 服务端验证"
icon={BadgeDollarSign}
/>
<FunnelChart
steps={data.monetization.purchaseFunnel}
emptyText="客户端购买事件暂无样本"
/>
<MetricRows
rows={[["取消购买用户", formatNumber(data.monetization.cancelledUsers)]]}
/>
</Card>
<Card className="overflow-hidden">
<SectionHeader
title="AI 终态延迟分布"
description="按客户端白名单耗时桶聚合;不推算虚假的精确分位数"
icon={Gauge}
/>
<ComparisonBarChart
items={data.guardrails.latencyBuckets.map((item) => ({
id: item.bucket,
label: latencyBucketLabel(item.bucket),
value: item.successful,
secondaryValue: item.failed,
}))}
primaryLabel="成功"
secondaryLabel="失败"
primaryTone="success"
secondaryTone="warning"
emptyText="客户端 AI 终态事件暂无样本"
/>
</Card>
</section>
<Card className="overflow-hidden">
<SectionHeader title="渠道质量" description="新增不是终点,重点比较 24 小时激活" icon={Users} />
<ChartToolbar label="渠道质量筛选与排序">
@@ -406,7 +460,7 @@ export function AnalyticsPage() {
value={channelSort}
order={channelOrder}
options={[
{ value: "installs", label: "新增安装" },
{ value: "installs", label: "已完成观察安装" },
{ value: "activated", label: "激活人数" },
{ value: "rate", label: "激活率" },
]}
@@ -425,7 +479,7 @@ export function AnalyticsPage() {
secondaryValue: channel.activated,
hint: `激活率 ${rateLabel(channel.activationRate)}`,
}))}
primaryLabel="新增安装"
primaryLabel="已完成 24h 观察安装"
secondaryLabel="24 小时激活"
emptyText="当前筛选条件下暂无渠道归因数据"
/>
@@ -535,3 +589,13 @@ function executionModeLabel(mode: string): string {
BYOK: "BYOK",
}[mode] ?? mode;
}
function latencyBucketLabel(bucket: string): string {
return {
LT_1S: "< 1 秒",
S1_TO_3: "13 秒",
S3_TO_10: "310 秒",
S10_TO_30: "1030 秒",
GTE_30S: "≥ 30 秒",
}[bucket] ?? bucket;
}
@@ -14,6 +14,7 @@ import { RadialMetric } from "../../components/charts/radial-metric";
import { TrendChart } from "../../components/charts/trend-chart";
import { ToggleFilter } from "../../components/filter-control";
import { Card, ErrorState, LoadingState, PageHeader, StatCard } from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber, usageTypeLabel } from "../../lib/format";
@@ -58,7 +59,7 @@ export function OverviewPage() {
if (!data) return <LoadingState label="加载运营总览" />;
const activeRate =
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : 0;
data.totalUsers > 0 ? Math.round((data.activeUsers / data.totalUsers) * 100) : null;
const usageRequests = data.usage.reduce((sum, item) => sum + item.requests, 0);
return (
@@ -66,8 +67,13 @@ export function OverviewPage() {
<PageHeader
eyebrow="核心指标"
title="运营总览"
description="聚合用户增长、活跃度与积分流转,快速识别业务变化。"
actions={<RangeControl value={range} onChange={setRange} />}
description="活跃用户指周期内成功使用 AI 或产生手动键盘输入的注册用户。"
actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4" aria-label="关键指标">
@@ -80,7 +86,7 @@ export function OverviewPage() {
<StatCard
label="活跃用户"
value={formatNumber(data.activeUsers)}
hint={`活跃率 ${activeRate}%`}
hint={`活跃率 ${activeRate == null ? "—" : `${activeRate}%`}`}
icon={Activity}
tone="success"
/>
@@ -105,7 +111,9 @@ export function OverviewPage() {
<div className="mb-7 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-base font-bold text-foreground"></h2>
<p className="mt-1 text-xs text-muted"> UTC </p>
<p className="mt-1 text-xs text-muted">
UTC
</p>
</div>
<ChartLegend
items={[
@@ -21,6 +21,7 @@ import {
PageHeader,
StatCard,
} from "../../components/primitives";
import { PeriodCaption } from "../../components/period-caption";
import { RangeControl } from "../../components/range-control";
import { SortControl } from "../../components/sort-control";
import { formatNumber } from "../../lib/format";
@@ -115,7 +116,7 @@ export function ReferralsPage() {
const first = data.funnel.at(0)?.count ?? 0;
const last = data.funnel.at(-1)?.count ?? 0;
const conversion = first > 0 ? Math.round((last / first) * 100) : 0;
const conversion = first > 0 ? Math.round((last / first) * 100) : null;
return (
<div className="space-y-6">
@@ -123,7 +124,12 @@ export function ReferralsPage() {
eyebrow="增长分析"
title="裂变与排行"
description="奖励以有效使用为前提,关注真实转化而不是单纯注册量。"
actions={<RangeControl value={range} onChange={setRange} />}
actions={
<div className="flex flex-col items-end gap-2">
<RangeControl value={range} onChange={setRange} />
<PeriodCaption from={data.period.from} until={data.period.until} />
</div>
}
/>
<section className="grid gap-4 sm:grid-cols-3">
@@ -143,7 +149,7 @@ export function ReferralsPage() {
/>
<StatCard
label="漏斗转化率"
value={`${conversion}%`}
value={conversion == null ? "—" : `${conversion}%`}
hint="首环节至最终有效使用"
icon={TrendingUp}
tone="success"
@@ -184,7 +190,7 @@ export function ReferralsPage() {
<div className="flex items-center justify-between border-b border-border p-5 sm:p-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted">使</p>
<p className="mt-1 text-xs text-muted"> AI </p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-primary-soft text-primary">
<GitBranch className="size-4" aria-hidden />
@@ -194,7 +200,7 @@ export function ReferralsPage() {
<RadialMetric
label="整体有效转化"
percent={conversion}
detail={`${formatNumber(first)}起点行为,最终形成 ${formatNumber(last)}有效使用`}
detail={`${formatNumber(first)}绑定,最终形成 ${formatNumber(last)}奖励`}
tone="success"
/>
</div>
@@ -205,7 +211,9 @@ export function ReferralsPage() {
<div className="flex items-center justify-between border-b border-border px-5 py-5 sm:px-6">
<div>
<h2 className="text-base font-bold"></h2>
<p className="mt-1 text-xs text-muted"></p>
<p className="mt-1 text-xs text-muted">
Top 8 {limit}
</p>
</div>
<span className="grid size-10 place-items-center rounded-2xl bg-warning-soft text-warning">
<Award className="size-4" aria-hidden />
+14 -1
View File
@@ -233,6 +233,10 @@ describe("React 管理页面", () => {
expect(screen.getByText("中文活跃用户")).toBeTruthy();
expect(screen.getByText("中英混合")).toBeTruthy();
expect(screen.getByText("7 天免费转付费")).toBeTruthy();
expect(screen.getByText("购买意向漏斗")).toBeTruthy();
expect(screen.getByText("AI 终态延迟分布")).toBeTruthy();
expect(screen.getByText("客户端分享信号")).toBeTruthy();
expect(screen.getByLabelText("实际统计周期").textContent).toContain("包含今日未完整数据");
});
it("产品图表可按执行模式筛选并按用户数稳定排序", async () => {
@@ -318,6 +322,12 @@ function analyticsOverview(): ProductAnalyticsOverview {
conversion7d: rate(8, 70, 11.4),
conversion30d: rate(10, 50, 20),
repeatPurchaseRate: rate(2, 10, 20),
purchaseFunnel: [
{ label: "浏览购买页", count: 30 },
{ label: "发起购买", count: 15 },
{ label: "StoreKit 验证完成", count: 10 },
],
cancelledUsers: 4,
},
growthFunnel: [
{ label: "首次启动", count: 100 },
@@ -353,14 +363,17 @@ function analyticsOverview(): ProductAnalyticsOverview {
mixedLanguageSessions: 30,
otherOnlySessions: 10,
},
referralSignals: { shared: 20, opened: 15 },
referralFunnel: [
{ label: "发起分享", count: 20 },
{ label: "完成绑定", count: 10 },
{ label: "绑定后首次 AI 成功", count: 8 },
{ label: "完成奖励", count: 5 },
],
guardrails: {
clientAiSuccessRate: rate(90, 100, 90),
managedSuccessRate: rate(95, 100, 95),
creditBlockedUsers: 3,
latencyBuckets: [{ bucket: "S1_TO_3", successful: 80, failed: 5 }],
},
};
}
+7 -1
View File
@@ -36,7 +36,10 @@ describe("高风险交互与 CSP", () => {
const { container } = render(
<>
<ComparisonBarChart
items={[{ label: "自然量", value: 100, secondaryValue: 60 }]}
items={[
{ label: "自然量", value: 100, secondaryValue: 60 },
{ label: "样本不足", value: null },
]}
primaryLabel="新增安装"
secondaryLabel="24 小时激活"
/>
@@ -51,6 +54,7 @@ describe("高风险交互与 CSP", () => {
]}
/>
<RadialMetric label="用户活跃率" percent={50} />
<RadialMetric label="无样本活跃率" percent={null} />
</>,
);
@@ -58,6 +62,8 @@ describe("高风险交互与 CSP", () => {
expect(screen.getByRole("progressbar", { name: /首次启动:100/ })).toBeTruthy();
expect(screen.getByText("D1、D7、D30 价值留存 cohort 热力图")).toBeTruthy();
expect(screen.getByRole("img", { name: "用户活跃率:50.0%" })).toBeTruthy();
expect(screen.getByRole("progressbar", { name: "样本不足 新增安装:暂无数据" })).toBeTruthy();
expect(screen.getByRole("img", { name: "无样本活跃率:暂无数据" })).toBeTruthy();
expect(container.querySelector("[style]")).toBeNull();
});