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:
Semih
2026-06-10 21:59:13 +03:00
parent 4eec4e7faf
commit 99965f4088
3 changed files with 283 additions and 0 deletions

View 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&apos;lı en yüksek dönüşüm. Lift = control&apos;e göre. (Basit dönüşüm; istatistiksel anlamlılık testi değil.)
</p>
</CardContent>
</Card>
);
}

View File

@@ -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>

View 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,
}));
}