feat(analytics): survey + experiment results in the archive detail view
Survey detail now shows the response funnel (shown -> sent -> dismissed) + per-question distributions (rating/choice bars, open-text list), keyed by $survey_id -> $survey_response_<questionId> from the archived definition. Feature-flag/experiment detail shows per-variant exposure -> conversion with lift-vs-control + a goal-event selector (signup/decode/trial/checkout/payment). All from posthog_events; no schema change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
139
apps/web/src/app/posthog-archive/[type]/[id]/_results.tsx
Normal file
139
apps/web/src/app/posthog-archive/[type]/[id]/_results.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
getSurveyResults,
|
||||
getExperimentResults,
|
||||
GOAL_EVENTS,
|
||||
} from "@/lib/analytics/experiment-results";
|
||||
|
||||
const pct = (n: number, d: number) => (d > 0 ? Math.round((n / d) * 100) : 0);
|
||||
|
||||
export async function SurveyResultsView({
|
||||
projectKey,
|
||||
surveyId,
|
||||
questions,
|
||||
}: {
|
||||
projectKey: string;
|
||||
surveyId: string;
|
||||
questions: Array<{ id?: string; question?: string; type?: string }>;
|
||||
}) {
|
||||
const { funnel, questions: qr } = await getSurveyResults(projectKey, surveyId, questions);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Survey sonuçları</CardTitle>
|
||||
<CardDescription>Gösterildi → yanıtlandı → kapatıldı + soru bazında dağılım</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<span>Gösterildi: <b className="tabular-nums">{funnel.shown}</b></span>
|
||||
<span>Yanıtlandı: <b className="tabular-nums">{funnel.sent}</b> <Badge variant="secondary">{funnel.responseRate}%</Badge></span>
|
||||
<span>Kapatıldı: <b className="tabular-nums">{funnel.dismissed}</b></span>
|
||||
</div>
|
||||
|
||||
{qr.map((q) => (
|
||||
<div key={q.id} className="space-y-1.5">
|
||||
<div className="text-sm font-medium">
|
||||
{q.question}{" "}
|
||||
<Badge variant="outline" className="ml-1">{q.type}</Badge>{" "}
|
||||
<span className="text-xs text-muted-foreground">· {q.total} yanıt</span>
|
||||
</div>
|
||||
{q.type === "open" ? (
|
||||
<ul className="ml-3 max-h-48 space-y-0.5 overflow-auto text-sm text-muted-foreground">
|
||||
{q.distribution.slice(0, 30).map((d, i) => (
|
||||
<li key={i}>“{d.value}”{d.count > 1 ? <span className="text-xs"> ×{d.count}</span> : null}</li>
|
||||
))}
|
||||
{q.distribution.length === 0 && <li className="text-xs">yanıt yok</li>}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{q.distribution.map((d) => (
|
||||
<div key={d.value} className="flex items-center gap-2 text-sm">
|
||||
<div className="w-16 shrink-0 text-right tabular-nums">{d.value}</div>
|
||||
<div className="h-3 flex-1 rounded-sm bg-muted">
|
||||
<div className="h-3 rounded-sm bg-primary" style={{ width: `${pct(d.count, q.total)}%` }} />
|
||||
</div>
|
||||
<div className="w-16 shrink-0 tabular-nums text-muted-foreground">
|
||||
{d.count} ({pct(d.count, q.total)}%)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{q.distribution.length === 0 && <div className="text-xs text-muted-foreground">yanıt yok</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ExperimentResultsView({
|
||||
projectKey,
|
||||
flagKey,
|
||||
goal,
|
||||
baseHref,
|
||||
}: {
|
||||
projectKey: string;
|
||||
flagKey: string;
|
||||
goal: string;
|
||||
baseHref: string;
|
||||
}) {
|
||||
const variants = await getExperimentResults(projectKey, flagKey, goal);
|
||||
const eligible = variants.filter((v) => v.exposed >= 30);
|
||||
const leader = eligible.length ? eligible.reduce((a, b) => (b.convRate > a.convRate ? b : a)) : null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Varyant sonuçları</CardTitle>
|
||||
<CardDescription>
|
||||
<span className="font-mono">{flagKey}</span> · exposure → dönüşüm
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Hedef:</span>
|
||||
{GOAL_EVENTS.map((g) => (
|
||||
<Link
|
||||
key={g}
|
||||
href={`${baseHref}?goal=${g}`}
|
||||
className={`rounded border px-1.5 py-0.5 ${g === goal ? "border-primary bg-primary/10 font-medium" : "text-muted-foreground"}`}
|
||||
>
|
||||
{g}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{variants.map((v) => (
|
||||
<div key={v.variant} className="flex items-center gap-2 text-sm">
|
||||
<div className="flex w-32 shrink-0 items-center gap-1.5">
|
||||
<span className="font-medium">{v.variant}</span>
|
||||
{leader && v.variant === leader.variant && <Badge className="h-4 px-1 text-[10px]">lider</Badge>}
|
||||
</div>
|
||||
<div className="h-4 flex-1 rounded-sm bg-muted">
|
||||
<div className="h-4 rounded-sm bg-primary/80" style={{ width: `${Math.min(100, v.convRate)}%` }} />
|
||||
</div>
|
||||
<div className="w-44 shrink-0 text-right tabular-nums text-xs text-muted-foreground">
|
||||
{v.converted}/{v.exposed} = <b className="text-foreground">{v.convRate}%</b>
|
||||
{v.liftVsControl !== null && (
|
||||
<span className={v.liftVsControl > 0 ? "ml-1 text-emerald-600" : "ml-1 text-red-600"}>
|
||||
{v.liftVsControl > 0 ? "+" : ""}
|
||||
{v.liftVsControl}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{variants.length === 0 && <div className="text-sm text-muted-foreground">exposure verisi yok</div>}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Lider = ≥30 exposure'lı en yüksek dönüşüm. Lift = control'e göre. (Basit dönüşüm; istatistiksel anlamlılık testi değil.)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { PanelShell } from "@/components/panel-shell";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getResourceHistory, TYPE_LABEL } from "@/lib/analytics/posthog-archive";
|
||||
import { GOAL_EVENTS } from "@/lib/analytics/experiment-results";
|
||||
import { SurveyResultsView, ExperimentResultsView } from "./_results";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -11,10 +13,13 @@ const PROJECT = "sase";
|
||||
|
||||
export default async function ResourceDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ type: string; id: string }>;
|
||||
searchParams: Promise<{ goal?: string }>;
|
||||
}) {
|
||||
const { type, id } = await params;
|
||||
const sp = await searchParams;
|
||||
const resourceId = decodeURIComponent(id);
|
||||
const history = await getResourceHistory(PROJECT, type, resourceId);
|
||||
if (history.length === 0) notFound();
|
||||
@@ -22,6 +27,24 @@ export default async function ResourceDetailPage({
|
||||
const latest = history[0];
|
||||
const data = (latest.data ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Results layer — surveys (responses) + experiments/flags (variant performance)
|
||||
const surveyId = type === "survey" ? resourceId : null;
|
||||
const surveyQuestions = Array.isArray(data.questions)
|
||||
? (data.questions as Array<{ id?: string; question?: string; type?: string }>)
|
||||
: [];
|
||||
const flagKey =
|
||||
type === "feature_flag"
|
||||
? typeof data.key === "string"
|
||||
? data.key
|
||||
: null
|
||||
: type === "experiment"
|
||||
? typeof data.feature_flag_key === "string"
|
||||
? data.feature_flag_key
|
||||
: null
|
||||
: null;
|
||||
const goal = (GOAL_EVENTS as readonly string[]).includes(sp.goal ?? "") ? (sp.goal as string) : "user_signed_up";
|
||||
const baseHref = `/posthog-archive/${type}/${encodeURIComponent(resourceId)}`;
|
||||
|
||||
const fields: Array<[string, string]> = [];
|
||||
for (const [k, label] of [
|
||||
["key", "key"],
|
||||
@@ -52,6 +75,11 @@ export default async function ResourceDetailPage({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{surveyId && <SurveyResultsView projectKey={PROJECT} surveyId={surveyId} questions={surveyQuestions} />}
|
||||
{flagKey && (
|
||||
<ExperimentResultsView projectKey={PROJECT} flagKey={flagKey} goal={goal} baseHref={baseHref} />
|
||||
)}
|
||||
|
||||
{fields.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
116
apps/web/src/lib/analytics/experiment-results.ts
Normal file
116
apps/web/src/lib/analytics/experiment-results.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// ── Survey results (from events: survey shown/sent/dismissed + $survey_response_*) ──
|
||||
export type SurveyFunnel = { shown: number; sent: number; dismissed: number; responseRate: number };
|
||||
export type SurveyQuestionResult = {
|
||||
id: string;
|
||||
question: string;
|
||||
type: string;
|
||||
total: number;
|
||||
distribution: Array<{ value: string; count: number }>;
|
||||
};
|
||||
export type SurveyResults = { funnel: SurveyFunnel; questions: SurveyQuestionResult[] };
|
||||
|
||||
export async function getSurveyResults(
|
||||
projectKey: string,
|
||||
surveyId: string,
|
||||
questions: Array<{ id?: string; question?: string; type?: string }>,
|
||||
): Promise<SurveyResults> {
|
||||
const since = new Date(Date.now() - 365 * 864e5); // surveys are low-volume → wide window
|
||||
const [f] = await prisma.$queryRaw<Array<{ shown: bigint; sent: bigint; dismissed: bigint }>>`
|
||||
SELECT
|
||||
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='survey shown') AS shown,
|
||||
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='survey sent') AS sent,
|
||||
count(DISTINCT coalesce("personId","distinctId")) FILTER (WHERE event='survey dismissed') AS dismissed
|
||||
FROM posthog_events
|
||||
WHERE "projectKey"=${projectKey} AND properties->>'$survey_id'=${surveyId} AND timestamp >= ${since}
|
||||
`;
|
||||
const shown = Number(f?.shown ?? 0);
|
||||
const sent = Number(f?.sent ?? 0);
|
||||
const dismissed = Number(f?.dismissed ?? 0);
|
||||
const funnel: SurveyFunnel = {
|
||||
shown,
|
||||
sent,
|
||||
dismissed,
|
||||
responseRate: shown > 0 ? Math.round((sent / shown) * 1000) / 10 : 0,
|
||||
};
|
||||
|
||||
const qResults: SurveyQuestionResult[] = [];
|
||||
for (const q of questions) {
|
||||
if (!q.id) continue;
|
||||
const key = `$survey_response_${q.id}`;
|
||||
const rows = await prisma.$queryRaw<Array<{ value: string; count: bigint }>>`
|
||||
SELECT properties->>${key} AS value, count(*) AS count
|
||||
FROM posthog_events
|
||||
WHERE "projectKey"=${projectKey} AND event='survey sent' AND properties->>'$survey_id'=${surveyId}
|
||||
AND properties->>${key} IS NOT NULL AND properties->>${key} <> ''
|
||||
GROUP BY 1 ORDER BY count DESC LIMIT 50
|
||||
`;
|
||||
const distribution = rows.map((r) => ({ value: r.value, count: Number(r.count) }));
|
||||
qResults.push({
|
||||
id: q.id,
|
||||
question: q.question ?? "(soru)",
|
||||
type: q.type ?? "open",
|
||||
total: distribution.reduce((a, d) => a + d.count, 0),
|
||||
distribution,
|
||||
});
|
||||
}
|
||||
return { funnel, questions: qResults };
|
||||
}
|
||||
|
||||
// ── Experiment / feature-flag results (variant exposure → conversion) ──
|
||||
export const GOAL_EVENTS = [
|
||||
"user_signed_up",
|
||||
"vin_decode_success",
|
||||
"trial_started",
|
||||
"checkout_started",
|
||||
"payment_success",
|
||||
] as const;
|
||||
export type GoalEvent = (typeof GOAL_EVENTS)[number];
|
||||
|
||||
export type VariantResult = {
|
||||
variant: string;
|
||||
exposed: number;
|
||||
converted: number;
|
||||
convRate: number;
|
||||
liftVsControl: number | null;
|
||||
};
|
||||
|
||||
export async function getExperimentResults(
|
||||
projectKey: string,
|
||||
flagKey: string,
|
||||
goalEvent: string,
|
||||
): Promise<VariantResult[]> {
|
||||
const since = new Date(Date.now() - 90 * 864e5);
|
||||
const rows = await prisma.$queryRaw<Array<{ variant: string; exposed: bigint; converted: bigint }>>`
|
||||
WITH exposed AS (
|
||||
SELECT DISTINCT coalesce("personId","distinctId") AS pid, properties->>'$feature_flag_response' AS variant
|
||||
FROM posthog_events
|
||||
WHERE "projectKey"=${projectKey} AND event='$feature_flag_called'
|
||||
AND properties->>'$feature_flag'=${flagKey}
|
||||
AND properties->>'$feature_flag_response' IS NOT NULL AND properties->>'$feature_flag_response' <> ''
|
||||
AND timestamp >= ${since}
|
||||
),
|
||||
conv AS (
|
||||
SELECT DISTINCT coalesce("personId","distinctId") AS pid
|
||||
FROM posthog_events
|
||||
WHERE "projectKey"=${projectKey} AND event=${goalEvent} AND timestamp >= ${since}
|
||||
)
|
||||
SELECT e.variant AS variant, count(DISTINCT e.pid) AS exposed, count(DISTINCT c.pid) AS converted
|
||||
FROM exposed e LEFT JOIN conv c ON c.pid = e.pid
|
||||
GROUP BY e.variant ORDER BY exposed DESC
|
||||
`;
|
||||
const base = rows.map((r) => {
|
||||
const exposed = Number(r.exposed);
|
||||
const converted = Number(r.converted);
|
||||
return { variant: r.variant, exposed, converted, convRate: exposed > 0 ? Math.round((converted / exposed) * 1000) / 10 : 0 };
|
||||
});
|
||||
const control = base.find((b) => /control|baseline/i.test(b.variant));
|
||||
return base.map((b) => ({
|
||||
...b,
|
||||
liftVsControl:
|
||||
control && control.convRate > 0 && b.variant !== control.variant
|
||||
? Math.round(((b.convRate - control.convRate) / control.convRate) * 1000) / 10
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user