ok
This commit is contained in:
@@ -60,9 +60,9 @@ func (r *AnalyticsRepo) Funnel(ctx context.Context, q FunnelQuery) (*model.Query
|
||||
|
||||
args := []any{
|
||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
||||
clickhouse.Named("window_seconds", q.WindowSeconds),
|
||||
clickhouse.Named("from", chTime(q.From)),
|
||||
clickhouse.Named("to", chTime(q.To)),
|
||||
clickhouse.Named("window_seconds", chUint(uint64(q.WindowSeconds))),
|
||||
}
|
||||
for i, name := range q.Steps {
|
||||
args = append(args, clickhouse.Named(fmt.Sprintf("step%d", i), name))
|
||||
@@ -112,8 +112,8 @@ func (r *AnalyticsRepo) Retention(ctx context.Context, q RetentionQuery) (*model
|
||||
|
||||
rows, err := r.ch.Query(ctx, sql,
|
||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
||||
clickhouse.Named("from", chTime(q.From)),
|
||||
clickhouse.Named("to", chTime(q.To)),
|
||||
clickhouse.Named("initial_event", q.InitialEvent),
|
||||
clickhouse.Named("return_event", q.ReturnEvent),
|
||||
)
|
||||
@@ -148,11 +148,11 @@ func (r *AnalyticsRepo) Sessions(ctx context.Context, q SessionQuery) (*model.Qu
|
||||
|
||||
args := []any{
|
||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
||||
clickhouse.Named("timeout_seconds", q.TimeoutSeconds),
|
||||
clickhouse.Named("limit", uint32(q.Limit)),
|
||||
clickhouse.Named("offset", uint32(q.Offset)),
|
||||
clickhouse.Named("from", chTime(q.From)),
|
||||
clickhouse.Named("to", chTime(q.To)),
|
||||
clickhouse.Named("timeout_seconds", chUint(uint64(q.TimeoutSeconds))),
|
||||
clickhouse.Named("limit", chUint(uint64(q.Limit))),
|
||||
clickhouse.Named("offset", chUint(uint64(q.Offset))),
|
||||
}
|
||||
if q.UserID != "" {
|
||||
args = append(args, clickhouse.Named("user_id", q.UserID))
|
||||
|
||||
@@ -3,6 +3,8 @@ package repo
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
@@ -11,6 +13,18 @@ import (
|
||||
"github.com/dbiz/cdp/data-layer/api/internal/templates"
|
||||
)
|
||||
|
||||
// chTime formats a Go time.Time for ClickHouse server-side query parameters.
|
||||
// clickhouse-go v2 routes args declared via {name:Type} syntax through the
|
||||
// server-side parameter protocol, which only accepts string values -- typed
|
||||
// helpers like clickhouse.DateNamed fail with
|
||||
// "expected string value in NamedValue for query parameter".
|
||||
// We emit the format ClickHouse parses for DateTime64(3,'UTC').
|
||||
func chTime(t time.Time) string {
|
||||
return t.UTC().Format("2006-01-02 15:04:05.000")
|
||||
}
|
||||
|
||||
func chUint(n uint64) string { return strconv.FormatUint(n, 10) }
|
||||
|
||||
type EventRepo struct {
|
||||
ch driver.Conn
|
||||
tpl *templates.Store
|
||||
@@ -39,10 +53,10 @@ func (r *EventRepo) QueryEvents(ctx context.Context, q model.EventQuery) (*model
|
||||
|
||||
args := []any{
|
||||
clickhouse.Named("workspace_id", q.WorkspaceID),
|
||||
clickhouse.DateNamed("from", q.From, clickhouse.MilliSeconds),
|
||||
clickhouse.DateNamed("to", q.To, clickhouse.MilliSeconds),
|
||||
clickhouse.Named("limit", uint32(q.Limit)),
|
||||
clickhouse.Named("offset", uint32(q.Offset)),
|
||||
clickhouse.Named("from", chTime(q.From)),
|
||||
clickhouse.Named("to", chTime(q.To)),
|
||||
clickhouse.Named("limit", chUint(uint64(q.Limit))),
|
||||
clickhouse.Named("offset", chUint(uint64(q.Offset))),
|
||||
}
|
||||
if q.UserID != "" {
|
||||
args = append(args, clickhouse.Named("user_id", q.UserID))
|
||||
@@ -73,8 +87,8 @@ func (r *EventRepo) QueryProfileTimeline(ctx context.Context, workspaceID, userI
|
||||
rows, err := r.ch.Query(ctx, sql,
|
||||
clickhouse.Named("workspace_id", workspaceID),
|
||||
clickhouse.Named("user_id", userID),
|
||||
clickhouse.Named("limit", uint32(limit)),
|
||||
clickhouse.Named("offset", uint32(offset)),
|
||||
clickhouse.Named("limit", chUint(uint64(limit))),
|
||||
clickhouse.Named("offset", chUint(uint64(offset))),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clickhouse query: %w", err)
|
||||
@@ -146,7 +160,8 @@ func newScanTarget(typeName string) any {
|
||||
var v bool
|
||||
return &v
|
||||
case "time.Time":
|
||||
return new(any) // let driver fill, deref below handles it
|
||||
var v time.Time
|
||||
return &v
|
||||
case "map[string]string":
|
||||
var v map[string]string
|
||||
return &v
|
||||
@@ -186,6 +201,8 @@ func derefScanTarget(p any) any {
|
||||
return *v
|
||||
case *[]string:
|
||||
return *v
|
||||
case *time.Time:
|
||||
return *v
|
||||
case *any:
|
||||
return *v
|
||||
default:
|
||||
|
||||
@@ -1,94 +1,102 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { analytics, type QueryResult } from '@/api/client';
|
||||
import { useWorkspace } from '@/stores/workspace';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
/**
|
||||
* Dashboard runs a handful of canned SELECTs against ClickHouse via
|
||||
* POST /query/sql and renders them. Every card refreshes every 10 seconds.
|
||||
* Business overview for the e-commerce demo data (Product Viewed /
|
||||
* Product Added / Order Completed). Auto-refreshes every 15 s.
|
||||
*
|
||||
* We bias toward small queries with explicit time bounds so the workspace's
|
||||
* Redis cache rejects them and the dashboard stays live.
|
||||
* Pure ClickHouse queries against analytics.events_track via POST /query/sql.
|
||||
*/
|
||||
|
||||
const REFRESH_MS = 10_000;
|
||||
const REFRESH_MS = 15_000;
|
||||
const WINDOW = '1 HOUR';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQL bundles
|
||||
// SQL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SQL = {
|
||||
totalLastHour: `
|
||||
SELECT count() AS total
|
||||
// Revenue, orders, AOV all from the same row to keep network round-trips down.
|
||||
orderKpis: `
|
||||
SELECT
|
||||
countIf(event = 'Order Completed') AS orders,
|
||||
sumIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS revenue,
|
||||
avgIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS aov
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR`,
|
||||
WHERE received_at >= now() - INTERVAL ${WINDOW}`,
|
||||
|
||||
uniqueUsersLastHour: `
|
||||
SELECT uniqExact(user_id) AS uniq_users
|
||||
activeCustomers: `
|
||||
SELECT uniqExact(user_id) AS active
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR
|
||||
WHERE received_at >= now() - INTERVAL ${WINDOW}
|
||||
AND user_id != ''`,
|
||||
|
||||
uniqueAnonLastHour: `
|
||||
SELECT uniqExact(anonymous_id) AS uniq_anon
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR
|
||||
AND anonymous_id != ''`,
|
||||
|
||||
// Last 5 minutes, grouped by second. ClickHouse fills gaps via WITH FILL.
|
||||
throughputPerSecond: `
|
||||
// Funnel counts for the canonical 3-step e-commerce funnel.
|
||||
funnel: `
|
||||
SELECT
|
||||
toStartOfSecond(received_at) AS sec,
|
||||
count() AS events
|
||||
countIf(event = 'Product Viewed') AS viewed,
|
||||
countIf(event = 'Product Added') AS added,
|
||||
countIf(event = 'Order Completed') AS completed
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 5 MINUTE
|
||||
GROUP BY sec
|
||||
ORDER BY sec WITH FILL STEP INTERVAL 1 SECOND`,
|
||||
WHERE received_at >= now() - INTERVAL ${WINDOW}`,
|
||||
|
||||
topEvents: `
|
||||
// Top products by views, with the matching "added to cart" count for context.
|
||||
topProducts: `
|
||||
SELECT
|
||||
event AS name,
|
||||
count() AS events
|
||||
properties['name'] AS product,
|
||||
properties['category'] AS category,
|
||||
countIf(event = 'Product Viewed') AS views,
|
||||
countIf(event = 'Product Added') AS added,
|
||||
round(
|
||||
if(countIf(event = 'Product Viewed') > 0,
|
||||
countIf(event = 'Product Added') / countIf(event = 'Product Viewed'),
|
||||
0
|
||||
) * 100, 1
|
||||
) AS add_rate_pct
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR
|
||||
AND event != ''
|
||||
GROUP BY name
|
||||
ORDER BY events DESC
|
||||
WHERE event IN ('Product Viewed', 'Product Added')
|
||||
AND received_at >= now() - INTERVAL ${WINDOW}
|
||||
AND properties['name'] != ''
|
||||
GROUP BY product, category
|
||||
ORDER BY views DESC
|
||||
LIMIT 10`,
|
||||
|
||||
latencyPercentiles: `
|
||||
SELECT
|
||||
quantile(0.5)(dateDiff('millisecond', sent_at, received_at)) AS p50_ms,
|
||||
quantile(0.95)(dateDiff('millisecond', sent_at, received_at)) AS p95_ms,
|
||||
quantile(0.99)(dateDiff('millisecond', sent_at, received_at)) AS p99_ms,
|
||||
max(dateDiff('millisecond', sent_at, received_at)) AS max_ms
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR
|
||||
AND sent_at > toDateTime64('1971-01-01', 3)`,
|
||||
|
||||
recent: `
|
||||
recentOrders: `
|
||||
SELECT
|
||||
received_at,
|
||||
event,
|
||||
user_id,
|
||||
anonymous_id,
|
||||
message_id
|
||||
properties['order_id'] AS order_id,
|
||||
toFloat64OrZero(properties['total']) AS total,
|
||||
properties['currency'] AS currency,
|
||||
length(JSONExtractArrayRaw(properties['products'])) AS line_items
|
||||
FROM analytics.events_track
|
||||
WHERE event = 'Order Completed'
|
||||
ORDER BY received_at DESC
|
||||
LIMIT 20`,
|
||||
LIMIT 10`,
|
||||
|
||||
dlqLastHour: `
|
||||
SELECT count() AS failed
|
||||
FROM analytics.events_dlq
|
||||
WHERE received_at >= now() - INTERVAL 1 HOUR`,
|
||||
// Customers ranked by spend in the window.
|
||||
topCustomers: `
|
||||
SELECT
|
||||
user_id,
|
||||
anyHeavy(traits['email']) AS email,
|
||||
anyHeavy(traits['plan']) AS plan,
|
||||
countIf(event = 'Order Completed') AS orders,
|
||||
sumIf(toFloat64OrZero(properties['total']), event = 'Order Completed') AS revenue
|
||||
FROM analytics.events_track
|
||||
WHERE received_at >= now() - INTERVAL ${WINDOW}
|
||||
AND user_id != ''
|
||||
GROUP BY user_id
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// Hooks / helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useSQL(key: string, sql: string) {
|
||||
@@ -101,108 +109,168 @@ function useSQL(key: string, sql: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: read a single scalar cell out of a 1-row query.
|
||||
function scalar<T = number>(res: QueryResult | undefined, col = 0, fallback: T = 0 as T): T {
|
||||
if (!res || res.rows.length === 0) return fallback;
|
||||
const v = res.rows[0][col];
|
||||
return (v ?? fallback) as T;
|
||||
return (res.rows[0][col] ?? fallback) as T;
|
||||
}
|
||||
|
||||
const fmtNumber = (n: number) =>
|
||||
n >= 1_000_000 ? (n / 1_000_000).toFixed(1) + 'M'
|
||||
: n >= 1_000 ? (n / 1_000).toFixed(1) + 'K'
|
||||
: new Intl.NumberFormat().format(Math.round(n));
|
||||
|
||||
const fmtMoney = (n: number, currency = 'USD') =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 2 }).format(n);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DashboardPage() {
|
||||
const total = useSQL('total', SQL.totalLastHour);
|
||||
const users = useSQL('users', SQL.uniqueUsersLastHour);
|
||||
const anon = useSQL('anon', SQL.uniqueAnonLastHour);
|
||||
const dlq = useSQL('dlq', SQL.dlqLastHour);
|
||||
const tput = useSQL('tput', SQL.throughputPerSecond);
|
||||
const top = useSQL('top', SQL.topEvents);
|
||||
const lat = useSQL('lat', SQL.latencyPercentiles);
|
||||
const recent = useSQL('recent', SQL.recent);
|
||||
const orderKpis = useSQL('order_kpis', SQL.orderKpis);
|
||||
const activeCustomers = useSQL('active', SQL.activeCustomers);
|
||||
const funnel = useSQL('funnel', SQL.funnel);
|
||||
const topProducts = useSQL('top_products', SQL.topProducts);
|
||||
const recentOrders = useSQL('recent_orders', SQL.recentOrders);
|
||||
const topCustomers = useSQL('top_customers', SQL.topCustomers);
|
||||
|
||||
const tputData = (tput.data?.rows ?? []).map(([sec, events]) => ({
|
||||
sec: new Date(String(sec)).toLocaleTimeString(),
|
||||
events: Number(events ?? 0),
|
||||
}));
|
||||
const orders = Number(scalar(orderKpis.data, 0));
|
||||
const revenue = Number(scalar(orderKpis.data, 1));
|
||||
const aov = Number(scalar(orderKpis.data, 2));
|
||||
const active = Number(scalar(activeCustomers.data, 0));
|
||||
|
||||
const funnelRow = funnel.data?.rows[0] ?? [0, 0, 0];
|
||||
const viewed = Number(funnelRow[0] ?? 0);
|
||||
const added = Number(funnelRow[1] ?? 0);
|
||||
const completed = Number(funnelRow[2] ?? 0);
|
||||
const funnelData = [
|
||||
{ stage: 'Product Viewed', count: viewed, pct: 100 },
|
||||
{ stage: 'Product Added', count: added, pct: viewed > 0 ? (added / viewed) * 100 : 0 },
|
||||
{ stage: 'Order Completed', count: completed, pct: viewed > 0 ? (completed / viewed) * 100 : 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Overview</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Live view of <code>analytics.events_track</code>. Auto-refresh every {REFRESH_MS / 1000}s.
|
||||
Last 1 hour · refreshes every {REFRESH_MS / 1000}s
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* KPI strip */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Kpi title="Events (last 1h)" value={fmt(scalar<number>(total.data))} loading={total.isPending} />
|
||||
<Kpi title="Unique users (1h)" value={fmt(scalar<number>(users.data))} loading={users.isPending} />
|
||||
<Kpi title="Unique anonymous (1h)" value={fmt(scalar<number>(anon.data))} loading={anon.isPending} />
|
||||
<Kpi
|
||||
title="DLQ (1h)"
|
||||
value={fmt(scalar<number>(dlq.data))}
|
||||
loading={dlq.isPending}
|
||||
tone={scalar<number>(dlq.data) > 0 ? 'destructive' : 'default'}
|
||||
/>
|
||||
<Kpi title="Revenue" value={fmtMoney(revenue)} loading={orderKpis.isPending} />
|
||||
<Kpi title="Orders" value={fmtNumber(orders)} loading={orderKpis.isPending} />
|
||||
<Kpi title="Avg. order value" value={fmtMoney(aov || 0)} loading={orderKpis.isPending} />
|
||||
<Kpi title="Active customers" value={fmtNumber(active)} loading={activeCustomers.isPending} />
|
||||
</div>
|
||||
|
||||
{/* Throughput chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Throughput (events/sec)</CardTitle>
|
||||
<CardDescription>Last 5 minutes, bucketed per second.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-64">
|
||||
{tput.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={tputData} margin={{ left: 0, right: 0, top: 8, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="gFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="sec" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10 }} width={32} />
|
||||
<Tooltip contentStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="events" stroke="hsl(var(--primary))" fill="url(#gFill)" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two-up row: top events + latency */}
|
||||
{/* Funnel + Top products */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top events (1h)</CardTitle>
|
||||
<CardDescription>Top 10 by count.</CardDescription>
|
||||
<CardTitle>Purchase funnel</CardTitle>
|
||||
<CardDescription>Product Viewed → Added → Order Completed</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-72">
|
||||
{funnel.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={funnelData} layout="vertical" margin={{ left: 0, right: 24 }}>
|
||||
<CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} />
|
||||
<YAxis dataKey="stage" type="category" width={130} tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 12 }}
|
||||
formatter={(v: number, _name, ctx) =>
|
||||
[`${fmtNumber(v)} (${ctx.payload.pct.toFixed(1)}%)`, 'count']
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
|
||||
{funnelData.map((_, i) => (
|
||||
<Cell key={i} fill={['#3b82f6', '#8b5cf6', '#10b981'][i]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top products</CardTitle>
|
||||
<CardDescription>Most-viewed, with add-to-cart rate.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{top.isPending ? (
|
||||
{topProducts.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-1">event</th>
|
||||
<th className="py-1 text-right">count</th>
|
||||
<th className="py-1">product</th>
|
||||
<th className="py-1 text-right">views</th>
|
||||
<th className="py-1 text-right">added</th>
|
||||
<th className="py-1 text-right">rate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(top.data?.rows ?? []).map(([name, n], i) => (
|
||||
{(topProducts.data?.rows ?? []).map(([product, _cat, views, added, rate], i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1 font-mono">{String(name)}</td>
|
||||
<td className="py-1 text-right">{fmt(Number(n))}</td>
|
||||
<td className="py-1">{String(product)}</td>
|
||||
<td className="py-1 text-right">{fmtNumber(Number(views))}</td>
|
||||
<td className="py-1 text-right">{fmtNumber(Number(added))}</td>
|
||||
<td className="py-1 text-right text-muted-foreground">{Number(rate).toFixed(1)}%</td>
|
||||
</tr>
|
||||
))}
|
||||
{(topProducts.data?.rows ?? []).length === 0 && (
|
||||
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no product events yet —</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Top customers + Recent orders */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top customers</CardTitle>
|
||||
<CardDescription>Ranked by revenue in the window.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{topCustomers.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-1">customer</th>
|
||||
<th className="py-1">plan</th>
|
||||
<th className="py-1 text-right">orders</th>
|
||||
<th className="py-1 text-right">revenue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(topCustomers.data?.rows ?? []).map(([uid, email, plan, count, rev], i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1">
|
||||
<div>{String(email || uid)}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{String(uid)}</div>
|
||||
</td>
|
||||
<td className="py-1 text-muted-foreground">{String(plan ?? '')}</td>
|
||||
<td className="py-1 text-right">{fmtNumber(Number(count))}</td>
|
||||
<td className="py-1 text-right font-medium">{fmtMoney(Number(rev))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(topCustomers.data?.rows ?? []).length === 0 && (
|
||||
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no customer activity yet —</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
@@ -211,53 +279,42 @@ export function DashboardPage() {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sent → received latency (1h)</CardTitle>
|
||||
<CardDescription>Client clock vs server clock, milliseconds.</CardDescription>
|
||||
<CardTitle>Recent orders</CardTitle>
|
||||
<CardDescription>Last 10 completed orders.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-4 gap-3">
|
||||
<LatStat label="p50" v={scalar<number>(lat.data, 0)} loading={lat.isPending} />
|
||||
<LatStat label="p95" v={scalar<number>(lat.data, 1)} loading={lat.isPending} />
|
||||
<LatStat label="p99" v={scalar<number>(lat.data, 2)} loading={lat.isPending} />
|
||||
<LatStat label="max" v={scalar<number>(lat.data, 3)} loading={lat.isPending} />
|
||||
<CardContent>
|
||||
{recentOrders.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-1">when</th>
|
||||
<th className="py-1">customer</th>
|
||||
<th className="py-1 text-right">items</th>
|
||||
<th className="py-1 text-right">total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(recentOrders.data?.rows ?? []).map(([ts, uid, _orderId, total, currency, items], i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="py-1 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(String(ts)).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="py-1 font-mono text-xs">{String(uid)}</td>
|
||||
<td className="py-1 text-right">{fmtNumber(Number(items))}</td>
|
||||
<td className="py-1 text-right font-medium">{fmtMoney(Number(total), String(currency || 'USD'))}</td>
|
||||
</tr>
|
||||
))}
|
||||
{(recentOrders.data?.rows ?? []).length === 0 && (
|
||||
<tr><td colSpan={4} className="py-3 text-center text-muted-foreground">— no orders yet —</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent events table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent events</CardTitle>
|
||||
<CardDescription>20 most recent across the whole workspace.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-auto">
|
||||
{recent.isPending ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<table className="w-full text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="px-2 py-1">received_at</th>
|
||||
<th className="px-2 py-1">event</th>
|
||||
<th className="px-2 py-1">user_id</th>
|
||||
<th className="px-2 py-1">anonymous_id</th>
|
||||
<th className="px-2 py-1">message_id</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(recent.data?.rows ?? []).map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-2 py-1 whitespace-nowrap">{String(row[0])}</td>
|
||||
<td className="px-2 py-1">{String(row[1])}</td>
|
||||
<td className="px-2 py-1 max-w-[200px] truncate">{String(row[2] ?? '')}</td>
|
||||
<td className="px-2 py-1 max-w-[200px] truncate">{String(row[3] ?? '')}</td>
|
||||
<td className="px-2 py-1 max-w-[240px] truncate">{String(row[4])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -266,9 +323,7 @@ export function DashboardPage() {
|
||||
// Small components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Kpi({
|
||||
title, value, loading, tone = 'default',
|
||||
}: { title: string; value: string; loading: boolean; tone?: 'default' | 'destructive' }) {
|
||||
function Kpi({ title, value, loading }: { title: string; value: string; loading: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
@@ -276,24 +331,13 @@ function Kpi({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading
|
||||
? <div className="h-7 w-20 animate-pulse rounded bg-muted" />
|
||||
: <div className={`text-3xl font-semibold ${tone === 'destructive' ? 'text-destructive' : ''}`}>{value}</div>}
|
||||
? <div className="h-7 w-24 animate-pulse rounded bg-muted" />
|
||||
: <div className="text-3xl font-semibold tracking-tight">{value}</div>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LatStat({ label, v, loading }: { label: string; v: number; loading: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
{loading
|
||||
? <div className="h-6 w-14 mt-1 animate-pulse rounded bg-muted" />
|
||||
: <div className="text-xl font-mono">{Number(v).toFixed(1)} <span className="text-xs text-muted-foreground">ms</span></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -303,12 +347,3 @@ function Skeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||
return new Intl.NumberFormat().format(n);
|
||||
}
|
||||
|
||||
// keep one Badge import alive for future use
|
||||
void Badge;
|
||||
|
||||
@@ -1,10 +1,334 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Activity, BookmarkPlus, Repeat, ShoppingCart, Sparkles, TrendingUp, Wand2,
|
||||
} from 'lucide-react';
|
||||
import { analytics, ApiError, type QueryResult } from '@/api/client';
|
||||
import { useWorkspace } from '@/stores/workspace';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Cohort retention with pre-baked templates (PostHog-style).
|
||||
*
|
||||
* Users pick a template card -> form auto-fills and a query fires. Power
|
||||
* users can still tweak the form below before re-running. A future "Custom
|
||||
* builder" will replace the raw form with a typed expression UI.
|
||||
*/
|
||||
|
||||
const DEFAULT_PERIODS = 7;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Templates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: typeof Sparkles;
|
||||
initial_event: string;
|
||||
return_event: string;
|
||||
periods: number;
|
||||
}
|
||||
|
||||
const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: 'engaged-browsers',
|
||||
name: 'Engaged browsers',
|
||||
description: 'Of users who viewed a product, how many come back to browse on day N.',
|
||||
icon: Activity,
|
||||
initial_event: 'Product Viewed',
|
||||
return_event: 'Product Viewed',
|
||||
periods: 7,
|
||||
},
|
||||
{
|
||||
id: 'cart-to-purchase',
|
||||
name: 'Cart → purchase',
|
||||
description: 'Users who added to cart, then completed an order on day N. Conversion proxy.',
|
||||
icon: ShoppingCart,
|
||||
initial_event: 'Product Added',
|
||||
return_event: 'Order Completed',
|
||||
periods: 7,
|
||||
},
|
||||
{
|
||||
id: 'repeat-buyers',
|
||||
name: 'Repeat buyers',
|
||||
description: 'Of users who completed an order, how many bought again on day N. Loyalty.',
|
||||
icon: Repeat,
|
||||
initial_event: 'Order Completed',
|
||||
return_event: 'Order Completed',
|
||||
periods: 14,
|
||||
},
|
||||
{
|
||||
id: 'post-purchase-browse',
|
||||
name: 'Post-purchase browsing',
|
||||
description: 'After buying, do customers come back to browse? Engagement after revenue.',
|
||||
icon: TrendingUp,
|
||||
initial_event: 'Order Completed',
|
||||
return_event: 'Product Viewed',
|
||||
periods: 7,
|
||||
},
|
||||
{
|
||||
id: 're-engagement',
|
||||
name: 'Re-engagement',
|
||||
description: 'Of browsers, how many converted to a purchase on day N. Close the loop.',
|
||||
icon: Sparkles,
|
||||
initial_event: 'Product Viewed',
|
||||
return_event: 'Order Completed',
|
||||
periods: 14,
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
description: 'Use the form below to define your own cohort.',
|
||||
icon: Wand2,
|
||||
initial_event: 'Product Viewed',
|
||||
return_event: 'Product Viewed',
|
||||
periods: DEFAULT_PERIODS,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isoDate(daysOffset: number): string {
|
||||
const d = new Date();
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
d.setUTCDate(d.getUTCDate() + daysOffset);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RetentionPage() {
|
||||
const workspace = useWorkspace((s) => s.currentWorkspace);
|
||||
|
||||
const [activeId, setActiveId] = useState<string>(TEMPLATES[0].id);
|
||||
const [initialEvent, setInitialEvent] = useState(TEMPLATES[0].initial_event);
|
||||
const [returnEvent, setReturnEvent] = useState(TEMPLATES[0].return_event);
|
||||
const [periods, setPeriods] = useState(TEMPLATES[0].periods);
|
||||
const [from, setFrom] = useState(isoDate(-(TEMPLATES[0].periods + 1)));
|
||||
const [to, setTo] = useState(isoDate(1));
|
||||
|
||||
const run = useMutation<QueryResult, ApiError>({
|
||||
mutationFn: () =>
|
||||
analytics(workspace).queryRetention({
|
||||
initial_event: initialEvent,
|
||||
return_event: returnEvent,
|
||||
from, to,
|
||||
periods,
|
||||
}),
|
||||
});
|
||||
|
||||
function applyTemplate(t: Template, shouldRun = true) {
|
||||
setActiveId(t.id);
|
||||
setInitialEvent(t.initial_event);
|
||||
setReturnEvent(t.return_event);
|
||||
setPeriods(t.periods);
|
||||
setFrom(isoDate(-(t.periods + 1)));
|
||||
setTo(isoDate(1));
|
||||
if (shouldRun && t.id !== 'custom') {
|
||||
// Defer so the state above is committed before the request fires.
|
||||
setTimeout(() => run.mutate(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-run the first template once on mount.
|
||||
useEffect(() => {
|
||||
run.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Retention</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cohort retention curves.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Cohort retention</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick a template, or tweak the form below for a one-off cohort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Template gallery */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TEMPLATES.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const active = t.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => applyTemplate(t)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition',
|
||||
active
|
||||
? 'border-primary ring-2 ring-primary/30'
|
||||
: 'hover:border-primary/40 hover:bg-accent/30',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 mt-0.5 shrink-0 text-primary" />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium leading-none">{t.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{t.description}</div>
|
||||
{t.id !== 'custom' && (
|
||||
<div className="pt-1 text-[10px] font-mono text-muted-foreground">
|
||||
{t.initial_event} → {t.return_event} · {t.periods}d
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Form (always visible; flipping to custom on any field edit) */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Definition</CardTitle>
|
||||
<CardDescription>
|
||||
{activeId === 'custom' ? 'Build your own cohort.' : 'Tweak the selected template.'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" disabled title="Saving custom cohorts comes in a later phase">
|
||||
<BookmarkPlus className="mr-1 h-4 w-4" /> Save (soon)
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
<label className="text-sm flex flex-col gap-1">
|
||||
Initial event
|
||||
<Input value={initialEvent} onChange={(e) => { setInitialEvent(e.target.value); setActiveId('custom'); }} />
|
||||
</label>
|
||||
<label className="text-sm flex flex-col gap-1">
|
||||
Return event
|
||||
<Input value={returnEvent} onChange={(e) => { setReturnEvent(e.target.value); setActiveId('custom'); }} />
|
||||
</label>
|
||||
<label className="text-sm flex flex-col gap-1">
|
||||
From (received_at >=)
|
||||
<Input value={from} onChange={(e) => { setFrom(e.target.value); setActiveId('custom'); }} />
|
||||
</label>
|
||||
<label className="text-sm flex flex-col gap-1">
|
||||
To (received_at <)
|
||||
<Input value={to} onChange={(e) => { setTo(e.target.value); setActiveId('custom'); }} />
|
||||
</label>
|
||||
<label className="text-sm flex flex-col gap-1">
|
||||
Periods (days)
|
||||
<Input
|
||||
type="number"
|
||||
value={periods}
|
||||
min={1} max={30}
|
||||
onChange={(e) => { setPeriods(Number(e.target.value) || DEFAULT_PERIODS); setActiveId('custom'); }}
|
||||
/>
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<Button onClick={() => run.mutate()} disabled={run.isPending}>
|
||||
{run.isPending ? 'Running…' : 'Compute'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{run.error && (
|
||||
<Card>
|
||||
<CardHeader><CardTitle>Error</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<Badge variant="destructive">{run.error.status}</Badge>{' '}
|
||||
<span className="text-sm">{run.error.message}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{run.data && (
|
||||
<Matrix
|
||||
result={run.data}
|
||||
periods={periods}
|
||||
headline={TEMPLATES.find((t) => t.id === activeId)?.name ?? 'Custom cohort'}
|
||||
initialEvent={initialEvent}
|
||||
returnEvent={returnEvent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heatmap-ish retention matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Matrix({
|
||||
result, periods, headline, initialEvent, returnEvent,
|
||||
}: {
|
||||
result: QueryResult;
|
||||
periods: number;
|
||||
headline: string;
|
||||
initialEvent: string;
|
||||
returnEvent: string;
|
||||
}) {
|
||||
const rows = result.rows ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{headline}</CardTitle>
|
||||
<CardDescription>
|
||||
Cohort = day a user first triggered <code>{initialEvent}</code>.
|
||||
{' '}Cells show the share who triggered <code>{returnEvent}</code> on D+k.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-auto">
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">— no cohorts matched the filter —</div>
|
||||
) : (
|
||||
<table className="text-xs font-mono">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="px-2 py-1 text-left">cohort day</th>
|
||||
<th className="px-2 py-1 text-right">size</th>
|
||||
{Array.from({ length: periods }).map((_, i) => (
|
||||
<th key={i} className="px-2 py-1 text-right">D{i}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => {
|
||||
const cohortDay = String(row[0]).slice(0, 10);
|
||||
const cohortSize = Number(row[1] ?? 0);
|
||||
const dCells = row.slice(2, 2 + periods).map(Number);
|
||||
return (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="px-2 py-1 whitespace-nowrap">{cohortDay}</td>
|
||||
<td className="px-2 py-1 text-right">{cohortSize}</td>
|
||||
{dCells.map((n, di) => {
|
||||
const pct = cohortSize > 0 ? (n / cohortSize) * 100 : 0;
|
||||
return (
|
||||
<td
|
||||
key={di}
|
||||
className="px-2 py-1 text-right"
|
||||
style={{ background: heat(pct), color: pct > 60 ? 'white' : undefined }}
|
||||
title={`${n} of ${cohortSize}`}
|
||||
>
|
||||
{cohortSize === 0 ? '–' : `${pct.toFixed(0)}%`}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function heat(pct: number): string {
|
||||
if (pct <= 0) return 'transparent';
|
||||
const alpha = 0.08 + (Math.min(pct, 100) / 100) * 0.87;
|
||||
return `rgba(59, 130, 246, ${alpha.toFixed(2)})`;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
-- Retention Cohort -- of users whose first `initial_event` lands on day D,
|
||||
-- what share triggered `return_event` on day D+k for k in 1..Periods.
|
||||
--
|
||||
-- Required parameters (clickhouse.Named):
|
||||
-- We compute the cohort day in a CTE first, then LEFT JOIN events_track and
|
||||
-- count distinct returners per (cohort_day, day_offset). Doing it this way
|
||||
-- avoids ClickHouse's "aggregate inside another aggregate" restriction that
|
||||
-- the older retention()-based form ran into.
|
||||
--
|
||||
-- Required parameters (clickhouse.Named, string-valued):
|
||||
-- workspace_id : String
|
||||
-- from : DateTime64(3,'UTC')
|
||||
-- from : DateTime64(3,'UTC') (formatted as 'YYYY-MM-DD HH:MM:SS.mmm')
|
||||
-- to : DateTime64(3,'UTC')
|
||||
-- initial_event : String
|
||||
-- return_event : String
|
||||
--
|
||||
-- Template inputs:
|
||||
-- .Outer : []{ RIndex int; OffsetDay int; Last bool }
|
||||
-- One entry per follow-up day. RIndex is the position in the retention()
|
||||
-- output array; OffsetDay is the day delta from the cohort day.
|
||||
SELECT
|
||||
cohort_day,
|
||||
countIf(arrayElement(r, 1)) AS cohort_size,
|
||||
{{- range $p := .Outer }}
|
||||
countIf(arrayElement(r, {{ $p.RIndex }})) AS retained_d{{ $p.OffsetDay }}{{ if not $p.Last }},{{ end }}
|
||||
{{- end }}
|
||||
FROM (
|
||||
-- .Outer : []{ OffsetDay int; Last bool }
|
||||
WITH cohorts AS (
|
||||
SELECT
|
||||
user_id,
|
||||
toDate(min(if(event = {initial_event:String}, timestamp, NULL))) AS cohort_day,
|
||||
retention(
|
||||
event = {initial_event:String} AND toDate(timestamp) = cohort_day,
|
||||
{{- range $p := .Outer }}
|
||||
event = {return_event:String} AND toDate(timestamp) = addDays(cohort_day, {{ $p.OffsetDay }}){{ if not $p.Last }},{{ end }}
|
||||
{{- end }}
|
||||
) AS r
|
||||
toDate(min(timestamp)) AS cohort_day
|
||||
FROM events_track
|
||||
WHERE workspace_id = {workspace_id:String}
|
||||
AND received_at >= {from:DateTime64(3,'UTC')}
|
||||
AND received_at < {to:DateTime64(3,'UTC')}
|
||||
AND user_id != ''
|
||||
AND event IN ({initial_event:String}, {return_event:String})
|
||||
AND event = {initial_event:String}
|
||||
GROUP BY user_id
|
||||
HAVING cohort_day IS NOT NULL
|
||||
)
|
||||
GROUP BY cohort_day
|
||||
ORDER BY cohort_day
|
||||
SELECT
|
||||
c.cohort_day AS cohort_day,
|
||||
uniqExact(c.user_id) AS cohort_size,
|
||||
{{- range $p := .Outer }}
|
||||
uniqExactIf(c.user_id, e.event = {return_event:String} AND toDate(e.timestamp) = addDays(c.cohort_day, {{ $p.OffsetDay }})) AS retained_d{{ $p.OffsetDay }}{{ if not $p.Last }},{{ end }}
|
||||
{{- end }}
|
||||
FROM cohorts AS c
|
||||
LEFT JOIN events_track AS e
|
||||
ON e.workspace_id = {workspace_id:String}
|
||||
AND e.user_id = c.user_id
|
||||
AND e.received_at >= {from:DateTime64(3,'UTC')}
|
||||
AND e.received_at < {to:DateTime64(3,'UTC')}
|
||||
GROUP BY c.cohort_day
|
||||
ORDER BY c.cohort_day
|
||||
|
||||
Reference in New Issue
Block a user