wip
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
505
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,505 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta name="title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="keywords" content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics">
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta property="og:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta property="og:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta name="twitter:title" content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics">
|
||||
<meta name="twitter:description" content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted.">
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>Stop settling for basic metrics. Get real insights that actually help you build a better product.</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/realtime-dark.webp" alt="OpenPanel Real-time Analytics" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Real-time analytics - see events as they happen. No waiting, no delays.</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>Let's talk about what happens when you have a <strong>real product</strong> with <strong>real users</strong>.</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>"1 million free events!" they scream. Cute. Until you have an actual product with actual users doing actual things. Then suddenly you need to "talk to sales" and your wallet starts bleeding.</p>
|
||||
|
||||
<p>Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X. HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're hemorrhaging money just to understand what your users are doing, you magnificent fool.</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>You built a great fucking product. You have real traffic. Thousands, tens of thousands of visitors. But you're flying blind.</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>You see the traffic. You see the bounce rate. You see the referrers. You see where they're from. You have <strong>NO FUCKING IDEA</strong> what users actually do.</p>
|
||||
|
||||
<p>Where do they drop off? Do they come back? What features do they use? Why didn't they convert? Who the fuck knows! You're using a glorified hit counter with a pretty dashboard that tells you everything about geography and nothing about behavior.</p>
|
||||
|
||||
<p>Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch. They're all the same story: simple analytics with some goals you can define. Page views, visitors, countries, basic funnels. That's it. No retention analysis. No user profiles. No event tracking. No cohorts. No revenue tracking. Just... basic web analytics.</p>
|
||||
|
||||
<p>And when you finally need to understand your users—when you need to see where they drop off in your signup flow, or which features drive retention, or why your conversion rate is shit—you end up paying for a <strong>SECOND tool</strong> on top. Now you're paying for two subscriptions, managing two dashboards, and your users' data is split across two platforms like a bad divorce.</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>Look, I get it. A dollar is cheap. But you're getting exactly what you pay for: page views. That's it. No funnels. No retention. No user profiles. No event tracking. Just... page views.</p>
|
||||
|
||||
<p>Here's the thing: if you want to make <strong>good decisions</strong> about your product, you need to understand <strong>what your users are actually doing</strong>, not just where the fuck they're from.</p>
|
||||
|
||||
<p>OpenPanel gives you the full product analytics suite. Or self-host for <strong>FREE</strong> with <strong>UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>All the web analytics (page views, visitors, referrers) that the other tools give you</li>
|
||||
</ul>
|
||||
|
||||
<p>One Dollar Stats tells you 50,000 people visited from France. OpenPanel tells you why they didn't buy your baguette. That's the difference between vanity metrics and actual insights.</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>You want analytics that actually help you build a better product. Not vanity metrics. Not enterprise pricing. Not two separate tools.</p>
|
||||
|
||||
<p>To make good decisions, you need to understand <strong>what your users are doing</strong>, not just where they're from. You need to see where they drop off. You need to know which features they use. You need to understand why they convert or why they don't.</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it, audit it, own it. Self-host for FREE with unlimited events, or use our cloud</li>
|
||||
<li><strong>Price</strong>: Affordable pricing that scales, or FREE self-hosted (unlimited events, forever)</li>
|
||||
<li><strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x bigger, you performance-obsessed maniac)</li>
|
||||
<li><strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or your own servers if you self-host)</li>
|
||||
<li><strong>Full Suite</strong>: Web analytics + product analytics in one tool. No need for two subscriptions.</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/overview-dark.webp" alt="OpenPanel Overview Dashboard" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>OpenPanel overview showing web analytics and product analytics in one clean interface</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>Tired of watching your analytics bill grow every month? Tired of "talk to sales" when you hit their arbitrary limits? Tired of paying $2,000+/month just to understand your users?</p>
|
||||
|
||||
<p><strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can fork it. You can audit it. You can own it. And you can <strong>self-host it for FREE with UNLIMITED events</strong>.</p>
|
||||
|
||||
<p>That's right. Zero dollars. Unlimited events. All the features. Your data on your servers. No vendor lock-in. No surprise bills. No "enterprise sales" calls.</p>
|
||||
|
||||
<p>Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel self-hosted? <strong>$0/month</strong>. Forever.</p>
|
||||
|
||||
<p>Don't want to manage infrastructure? That's fine. Use our cloud. But if you want to escape the pricing hell entirely, self-hosting is a Docker command away. Your data, your rules, your wallet.</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>Simple analytics with basic goals. Page views and visitors. That's it.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td><strong>Web + Product analytics. The full package. Open source. Your data.</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/profile-dark.webp" alt="OpenPanel User Profiles" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>User profiles - see individual user journeys and behavior. Something web-only tools can't give you.</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/report-dark.webp" alt="OpenPanel Reports and Funnels" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Funnels, retention, and custom reports - the features you CAN'T get with web-only tools</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>If you want to make good decisions about your product, you need to understand what your users are actually doing. Not just where they're from. Not just how many page views you got. You need to see the full picture: funnels, retention, user behavior, conversion paths.</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>Keep using Google Analytics like a data-harvesting accomplice, adding cookie banners, annoying your users, and contributing to the dystopian surveillance economy</li>
|
||||
<li>Pay $2,000+/month for Mixpanel or PostHog when you scale, or use simple web-only analytics that tell you nothing about user behavior—just where they're from</li>
|
||||
<li>Use OpenPanel (affordable pricing or FREE self-hosted) and get the full analytics suite: web analytics AND product analytics in one tool, so you can actually understand what your users do</li>
|
||||
</ol>
|
||||
|
||||
<p>If you picked option 1 or 2, I can't help you. You're beyond saving. Go enjoy your complicated, privacy-violating, overpriced analytics life where you know everything about where your users are from but nothing about what they actually do.</p>
|
||||
|
||||
<p>But if you have even one functioning brain cell, you'll realize that OpenPanel gives you everything you need—web analytics AND product analytics—for a fraction of what the enterprise tools cost. You'll finally understand what your users are doing, not just where the fuck they're from.</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>Stop settling for vanity metrics. Get the full analytics suite—web analytics AND product analytics—so you can make better decisions. Or self-host for free.</p>
|
||||
<a href="https://openpanel.dev" target="_blank">Get Started with OpenPanel</a>
|
||||
<a href="https://openpanel.dev/docs/self-hosting/self-hosting" target="_blank">Self-Host Guide</a>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img src="screenshots/dashboard-dark.webp" alt="OpenPanel Custom Dashboards" width="1400" height="800">
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>Custom dashboards - build exactly what you need to understand your product</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>Inspired by <a href="https://justfuckingusereact.com" target="_blank" rel="nofollow">justfuckingusereact.com</a>, <a href="https://justfuckingusehtml.com" target="_blank" rel="nofollow">justfuckingusehtml.com</a>, and <a href="https://justfuckinguseonedollarstats.com" target="_blank" rel="nofollow">justfuckinguseonedollarstats.com</a> and all other just fucking use sites.</p>
|
||||
<p style="margin-top: 1rem;">This is affiliated with <a href="https://openpanel.dev" target="_blank" rel="nofollow">OpenPanel</a>. We still love all products mentioned in this website, and we're grateful for them and what they do 🫶</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
|
||||
import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
import { ReportSankeyChart } from './sankey';
|
||||
|
||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
return <ReportRetentionChart />;
|
||||
case 'conversion':
|
||||
return <ReportConversionChart />;
|
||||
case 'sankey':
|
||||
return <ReportSankeyChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
changeStartDate,
|
||||
ready,
|
||||
reset,
|
||||
setName,
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
@@ -20,7 +19,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { GanttChartSquareIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
|
||||
302
apps/start/src/components/report-chart/sankey/chart.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
ChartTooltipContainer,
|
||||
ChartTooltipHeader,
|
||||
ChartTooltipItem,
|
||||
} from '@/components/charts/chart-tooltip';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { round } from '@/utils/math';
|
||||
import { ResponsiveSankey } from '@nivo/sankey';
|
||||
import {
|
||||
type ReactNode,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useTheme } from '@/components/theme-provider';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
|
||||
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
|
||||
|
||||
function SankeyPortalTooltip({
|
||||
children,
|
||||
offset = 12,
|
||||
padding = 8,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
offset?: number;
|
||||
padding?: number;
|
||||
}) {
|
||||
const anchorRef = useRef<HTMLSpanElement | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
||||
const [pos, setPos] = useState<PortalTooltipPosition>({
|
||||
left: 0,
|
||||
top: 0,
|
||||
ready: false,
|
||||
});
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const wrapper = el.parentElement;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => {
|
||||
setAnchorRect(wrapper.getBoundingClientRect());
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
|
||||
window.addEventListener('scroll', update, true);
|
||||
window.addEventListener('resize', update);
|
||||
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
window.removeEventListener('scroll', update, true);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (!anchorRect) return;
|
||||
const tooltipEl = tooltipRef.current;
|
||||
if (!tooltipEl) return;
|
||||
|
||||
const rect = tooltipEl.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let left = anchorRect.left + offset;
|
||||
let top = anchorRect.top + offset;
|
||||
|
||||
left = Math.min(
|
||||
Math.max(padding, left),
|
||||
Math.max(padding, vw - rect.width - padding),
|
||||
);
|
||||
top = Math.min(
|
||||
Math.max(padding, top),
|
||||
Math.max(padding, vh - rect.height - padding),
|
||||
);
|
||||
|
||||
setPos({ left, top, ready: true });
|
||||
}, [mounted, anchorRect, children, offset, padding]);
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={anchorRef} className="sr-only" />
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className="pointer-events-none fixed z-[9999]"
|
||||
style={{
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
visibility: pos.ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type SankeyData = {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
};
|
||||
|
||||
export function Chart({ data }: { data: SankeyData }) {
|
||||
const number = useNumber();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { appTheme } = useTheme();
|
||||
|
||||
// Process data for Sankey
|
||||
const sankeyData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
|
||||
return {
|
||||
nodes: data.nodes.map((node) => ({
|
||||
...node,
|
||||
label: node.label || node.id,
|
||||
data: {
|
||||
percentage: node.percentage,
|
||||
value: node.value,
|
||||
step: node.step,
|
||||
label: node.label || node.id,
|
||||
},
|
||||
})),
|
||||
links: data.links,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const totalSessions = useMemo(() => {
|
||||
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
|
||||
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
|
||||
const base = step1.length > 0 ? step1 : sankeyData.nodes;
|
||||
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
|
||||
}, [sankeyData.nodes]);
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full relative aspect-square md:aspect-[2]"
|
||||
>
|
||||
<ResponsiveSankey
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
data={sankeyData}
|
||||
colors={(node: any) => node.nodeColor}
|
||||
nodeBorderRadius={2}
|
||||
animate={false}
|
||||
nodeBorderWidth={0}
|
||||
nodeOpacity={0.8}
|
||||
linkContract={1}
|
||||
linkOpacity={0.3}
|
||||
linkBlendMode={'normal'}
|
||||
nodeTooltip={({ node }: any) => {
|
||||
const label = node?.data?.label ?? node?.label ?? node?.id;
|
||||
const value = node?.data?.value ?? node?.value ?? 0;
|
||||
const step = node?.data?.step;
|
||||
const pct =
|
||||
typeof node?.data?.percentage === 'number'
|
||||
? node.data.percentage
|
||||
: totalSessions > 0
|
||||
? (value / totalSessions) * 100
|
||||
: 0;
|
||||
const color =
|
||||
node?.color ??
|
||||
node?.data?.nodeColor ??
|
||||
node?.data?.color ??
|
||||
node?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer className="min-w-[250px]">
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
{label}
|
||||
</div>
|
||||
{typeof step === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
Step {step}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Share</div>
|
||||
<div>{number.format(round(pct, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
linkTooltip={({ link }: any) => {
|
||||
const sourceLabel =
|
||||
link?.source?.data?.label ??
|
||||
link?.source?.label ??
|
||||
link?.source?.id;
|
||||
const targetLabel =
|
||||
link?.target?.data?.label ??
|
||||
link?.target?.label ??
|
||||
link?.target?.id;
|
||||
|
||||
const value = link?.value ?? 0;
|
||||
const sourceValue =
|
||||
link?.source?.data?.value ?? link?.source?.value ?? 0;
|
||||
|
||||
const pctOfTotal =
|
||||
totalSessions > 0 ? (value / totalSessions) * 100 : 0;
|
||||
const pctOfSource =
|
||||
sourceValue > 0 ? (value / sourceValue) * 100 : 0;
|
||||
|
||||
const sourceStep = link?.source?.data?.step;
|
||||
const targetStep = link?.target?.data?.step;
|
||||
|
||||
const color =
|
||||
link?.color ??
|
||||
link?.source?.color ??
|
||||
link?.source?.data?.nodeColor ??
|
||||
'#64748b';
|
||||
|
||||
return (
|
||||
<SankeyPortalTooltip>
|
||||
<ChartTooltipContainer>
|
||||
<ChartTooltipHeader>
|
||||
<div className="min-w-0 flex-1 font-medium break-words">
|
||||
{sourceLabel}
|
||||
<ArrowRightIcon className="size-2 inline-block mx-3" />
|
||||
{targetLabel}
|
||||
</div>
|
||||
{typeof sourceStep === 'number' &&
|
||||
typeof targetStep === 'number' && (
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{sourceStep} → {targetStep}
|
||||
</div>
|
||||
)}
|
||||
</ChartTooltipHeader>
|
||||
|
||||
<ChartTooltipItem color={color} innerClassName="gap-2">
|
||||
<div className="flex items-center justify-between gap-8 font-mono font-medium">
|
||||
<div className="text-muted-foreground">Sessions</div>
|
||||
<div>{number.format(value)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">% of total</div>
|
||||
<div>{number.format(round(pctOfTotal, 1))} %</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-8 font-mono text-sm">
|
||||
<div className="text-muted-foreground">% of source</div>
|
||||
<div>{number.format(round(pctOfSource, 1))} %</div>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</ChartTooltipContainer>
|
||||
</SankeyPortalTooltip>
|
||||
);
|
||||
}}
|
||||
label={(node: any) => {
|
||||
const label = node.data?.label || node.label || node.id;
|
||||
return truncate(label, 30, 'middle');
|
||||
}}
|
||||
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
|
||||
nodeSpacing={10}
|
||||
/>
|
||||
</div>
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
93
apps/start/src/components/report-chart/sankey/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportSankeyChart() {
|
||||
const {
|
||||
report: {
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
options,
|
||||
startDate,
|
||||
endDate,
|
||||
breakdowns,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
|
||||
if (!options) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
const input: IChartInput = {
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
chartType: 'sankey',
|
||||
breakdowns,
|
||||
options,
|
||||
metric: 'sum',
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 20,
|
||||
previous: false,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.sankey.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data.nodes.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
<Chart data={res.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ChartColumnIncreasingIcon,
|
||||
ConeIcon,
|
||||
GaugeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
LineChartIcon,
|
||||
type LucideIcon,
|
||||
@@ -58,6 +59,7 @@ export function ReportChartType({
|
||||
retention: UsersIcon,
|
||||
map: Globe2Icon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
|
||||
|
||||
import { shortId } from '@openpanel/common';
|
||||
import {
|
||||
@@ -12,12 +11,12 @@ import {
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
IReportOptions,
|
||||
UnionOmit,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
@@ -28,6 +27,7 @@ type InitialState = IChartProps & {
|
||||
ready: boolean;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
options?: IReportOptions;
|
||||
};
|
||||
|
||||
// First approach: define the initial state using that type
|
||||
@@ -53,6 +53,7 @@ const initialState: InitialState = {
|
||||
criteria: 'on_or_after',
|
||||
funnelGroup: undefined,
|
||||
funnelWindow: undefined,
|
||||
options: undefined,
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
@@ -187,6 +188,16 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.chartType = action.payload;
|
||||
|
||||
// Initialize sankey options if switching to sankey
|
||||
if (action.payload === 'sankey' && !state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||
state.interval === 'minute'
|
||||
@@ -271,6 +282,66 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.funnelWindow = action.payload || undefined;
|
||||
},
|
||||
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
|
||||
state.dirty = true;
|
||||
state.options = action.payload || undefined;
|
||||
},
|
||||
changeSankeyMode(
|
||||
state,
|
||||
action: PayloadAction<'between' | 'after' | 'before'>,
|
||||
) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: action.payload,
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.mode = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeySteps(state, action: PayloadAction<number>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: action.payload,
|
||||
exclude: [],
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.steps = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyExclude(state, action: PayloadAction<string[]>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.exclude = action.payload;
|
||||
}
|
||||
},
|
||||
changeSankeyInclude(state, action: PayloadAction<string[] | undefined>) {
|
||||
state.dirty = true;
|
||||
if (!state.options) {
|
||||
state.options = {
|
||||
type: 'sankey',
|
||||
mode: 'after',
|
||||
steps: 5,
|
||||
exclude: [],
|
||||
include: action.payload,
|
||||
};
|
||||
} else if (state.options.type === 'sankey') {
|
||||
state.options.include = action.payload;
|
||||
}
|
||||
},
|
||||
reorderEvents(
|
||||
state,
|
||||
action: PayloadAction<{ fromIndex: number; toIndex: number }>,
|
||||
@@ -311,6 +382,11 @@ export const {
|
||||
changeUnit,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changeOptions,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
reorderEvents,
|
||||
} = reportSlice.actions;
|
||||
|
||||
|
||||
@@ -23,15 +23,13 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
@@ -39,27 +37,21 @@ import {
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
import {
|
||||
ReportSeriesItem,
|
||||
type ReportSeriesItemProps,
|
||||
} from './ReportSeriesItem';
|
||||
|
||||
function SortableSeries({
|
||||
function SortableReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: eventId ?? '' });
|
||||
@@ -69,85 +61,26 @@ function SortableSeries({
|
||||
transition,
|
||||
};
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<ReportSeriesItem
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
renderDragHandle={(index) => (
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,12 +94,23 @@ export function ReportSeries() {
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention', 'sankey'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
const isSankey = chartType === 'sankey';
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const isSankeyEventLimitReached =
|
||||
isSankey &&
|
||||
options &&
|
||||
((options.type === 'sankey' &&
|
||||
options.mode === 'between' &&
|
||||
selectedSeries.length >= 2) ||
|
||||
(options.type === 'sankey' &&
|
||||
options.mode !== 'between' &&
|
||||
selectedSeries.length >= 1));
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
@@ -218,7 +162,8 @@ export function ReportSeries() {
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -239,7 +184,7 @@ export function ReportSeries() {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
<SortableReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
@@ -348,13 +293,14 @@ export function ReportSeries() {
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
</SortableReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
className="flex-1"
|
||||
disabled={isAddEventDisabled || isSankeyEventLimitReached}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
@@ -393,6 +339,7 @@ export function ReportSeries() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
className="flex-1 justify-start text-left"
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
@@ -405,6 +352,7 @@ export function ReportSeries() {
|
||||
className="px-4"
|
||||
>
|
||||
Add Formula
|
||||
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
114
apps/start/src/components/report/sidebar/ReportSeriesItem.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
|
||||
import { FilterIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
export interface ReportSeriesItemProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
renderDragHandle?: (index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function ReportSeriesItem({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
renderDragHandle,
|
||||
...props
|
||||
}: ReportSeriesItemProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
{renderDragHandle ? (
|
||||
renderDragHandle(index)
|
||||
) : (
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
)}
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
changeCriteria,
|
||||
changeFunnelGroup,
|
||||
changeFunnelWindow,
|
||||
changePrevious,
|
||||
changeSankeyExclude,
|
||||
changeSankeyInclude,
|
||||
changeSankeyMode,
|
||||
changeSankeySteps,
|
||||
changeUnit,
|
||||
} from '../reportSlice';
|
||||
|
||||
@@ -20,13 +27,16 @@ export function ReportSettings() {
|
||||
const unit = useSelector((state) => state.report.unit);
|
||||
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
||||
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
||||
const options = useSelector((state) => state.report.options);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({ projectId });
|
||||
|
||||
const fields = useMemo(() => {
|
||||
const fields = [];
|
||||
|
||||
if (chartType !== 'retention') {
|
||||
if (chartType !== 'retention' && chartType !== 'sankey') {
|
||||
fields.push('previous');
|
||||
}
|
||||
|
||||
@@ -40,6 +50,13 @@ export function ReportSettings() {
|
||||
fields.push('funnelWindow');
|
||||
}
|
||||
|
||||
if (chartType === 'sankey') {
|
||||
fields.push('sankeyMode');
|
||||
fields.push('sankeySteps');
|
||||
fields.push('sankeyExclude');
|
||||
fields.push('sankeyInclude');
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [chartType]);
|
||||
|
||||
@@ -149,6 +166,89 @@ export function ReportSettings() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Mode</span>
|
||||
<Combobox
|
||||
align="end"
|
||||
placeholder="Select mode"
|
||||
value={options?.mode || 'after'}
|
||||
onChange={(val) => {
|
||||
dispatch(
|
||||
changeSankeyMode(val as 'between' | 'after' | 'before'),
|
||||
);
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
label: 'After',
|
||||
value: 'after',
|
||||
},
|
||||
{
|
||||
label: 'Before',
|
||||
value: 'before',
|
||||
},
|
||||
{
|
||||
label: 'Between',
|
||||
value: 'between',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeySteps') && options?.type === 'sankey' && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="whitespace-nowrap font-medium">Steps</span>
|
||||
<InputEnter
|
||||
type="number"
|
||||
value={options?.steps ? String(options.steps) : '5'}
|
||||
placeholder="Default: 5"
|
||||
onChangeValue={(value) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 2 || parsed > 10) {
|
||||
dispatch(changeSankeySteps(5));
|
||||
} else {
|
||||
dispatch(changeSankeySteps(parsed));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyExclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="whitespace-nowrap font-medium">
|
||||
Exclude Events
|
||||
</span>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.exclude || []}
|
||||
onChange={(value) => {
|
||||
dispatch(changeSankeyExclude(value));
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Select events to exclude"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{fields.includes('sankeyInclude') && options?.type === 'sankey' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="whitespace-nowrap font-medium">
|
||||
Include events
|
||||
</span>
|
||||
<ComboboxEvents
|
||||
multiple
|
||||
searchable
|
||||
value={options?.include || []}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeSankeyInclude(value.length > 0 ? value : undefined),
|
||||
);
|
||||
}}
|
||||
items={eventNames.filter((item) => item.name !== '*')}
|
||||
placeholder="Leave empty to include all"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
import { ReportFixedEvents } from './report-fixed-events';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
const { chartType, options } = useSelector((state) => state.report);
|
||||
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
|
||||
const showFixedEvents = chartType === 'sankey';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportSeries />
|
||||
{showFixedEvents ? (
|
||||
<ReportFixedEvents
|
||||
numberOfEvents={
|
||||
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ReportSeries />
|
||||
)}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
|
||||
223
apps/start/src/components/report/sidebar/report-fixed-events.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
} from '../reportSlice';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { ReportSeriesItem } from './ReportSeriesItem';
|
||||
|
||||
export function ReportFixedEvents({
|
||||
numberOfEvents,
|
||||
}: {
|
||||
numberOfEvents: number;
|
||||
}) {
|
||||
const selectedSeries = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: 'type' in event ? event.id : (event as IChartEvent).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
const normalized =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'sankey';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: numberOfEvents }, (_, index) => ({
|
||||
slotId: `fixed-event-slot-${index}`,
|
||||
index,
|
||||
})).map(({ slotId, index }) => {
|
||||
const event = selectedSeries[index];
|
||||
|
||||
// If no event exists at this index, render an empty slot
|
||||
if (!event) {
|
||||
return (
|
||||
<div key={slotId} className="rounded-lg border bg-def-100">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<ColorSquare>
|
||||
<span className="block">{alphabetIds[index]}</span>
|
||||
</ColorSquare>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={''}
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFormula = event.type === 'formula';
|
||||
if (isFormula) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ReportSeriesItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).filters[0]?.value ?? [])
|
||||
: (
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(event as IChartEventItem & { type: 'event' }).name
|
||||
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={
|
||||
(event as IChartEventItem & { type: 'event' }).displayName
|
||||
}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</ReportSeriesItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -178,7 +178,7 @@ export function ComboboxEvents<
|
||||
|
||||
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||
<VirtualList
|
||||
height={400}
|
||||
height={300}
|
||||
data={items.filter((item) => {
|
||||
if (search === '') return true;
|
||||
return item.name.toLowerCase().includes(search.toLowerCase());
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BarChartHorizontalIcon,
|
||||
ChartScatterIcon,
|
||||
ConeIcon,
|
||||
GitBranchIcon,
|
||||
Globe2Icon,
|
||||
HashIcon,
|
||||
LayoutPanelTopIcon,
|
||||
@@ -153,6 +154,7 @@ function Component() {
|
||||
area: AreaChartIcon,
|
||||
retention: ChartScatterIcon,
|
||||
conversion: TrendingUpIcon,
|
||||
sankey: GitBranchIcon,
|
||||
}[report.chartType];
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,5 +36,6 @@ function Component() {
|
||||
const { reportId } = Route.useParams();
|
||||
const trpc = useTRPC();
|
||||
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
|
||||
console.log(query.data);
|
||||
return <ReportEditor report={query.data} />;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ export const chartTypes = {
|
||||
funnel: 'Funnel',
|
||||
retention: 'Retention',
|
||||
conversion: 'Conversion',
|
||||
sankey: 'Sankey',
|
||||
} as const;
|
||||
|
||||
export const chartSegments = {
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from './src/services/share.service';
|
||||
export * from './src/services/session.service';
|
||||
export * from './src/services/funnel.service';
|
||||
export * from './src/services/conversion.service';
|
||||
export * from './src/services/sankey.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/id.service';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "public"."ChartType" ADD VALUE 'sankey';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."reports" ADD COLUMN "options" JSONB;
|
||||
@@ -279,6 +279,7 @@ enum ChartType {
|
||||
funnel
|
||||
retention
|
||||
conversion
|
||||
sankey
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
@@ -321,6 +322,8 @@ model Report {
|
||||
criteria String?
|
||||
funnelGroup String?
|
||||
funnelWindow Float?
|
||||
/// [IReportOptions]
|
||||
options Json?
|
||||
|
||||
dashboardId String
|
||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -203,6 +203,13 @@ export class Query<T = any> {
|
||||
return this;
|
||||
}
|
||||
|
||||
rawHaving(condition: string): this {
|
||||
if (condition) {
|
||||
this._having.push({ condition, operator: 'AND' });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
andHaving(column: string, operator: Operator, value: SqlParam): this {
|
||||
const condition = this.buildCondition(column, operator, value);
|
||||
this._having.push({ condition, operator: 'AND' });
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
ICriteria,
|
||||
IReportOptions,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||
@@ -65,7 +65,13 @@ export function transformReportEventItem(
|
||||
|
||||
export function transformReport(
|
||||
report: DbReport & { layout?: ReportLayout | null },
|
||||
): IChartProps & { id: string; layout?: ReportLayout | null } {
|
||||
): IChartProps & {
|
||||
id: string;
|
||||
layout?: ReportLayout | null;
|
||||
} {
|
||||
// Parse options from JSON field, fallback to legacy fields for backward compatibility
|
||||
const options = report.options as IReportOptions | null | undefined;
|
||||
|
||||
return {
|
||||
id: report.id,
|
||||
projectId: report.projectId,
|
||||
@@ -84,9 +90,13 @@ export function transformReport(
|
||||
formula: report.formula ?? undefined,
|
||||
metric: report.metric ?? 'sum',
|
||||
unit: report.unit ?? undefined,
|
||||
criteria: (report.criteria as ICriteria) ?? undefined,
|
||||
criteria: (report.criteria ?? 'on_or_after') as
|
||||
| 'on_or_after'
|
||||
| 'on'
|
||||
| undefined,
|
||||
funnelGroup: report.funnelGroup ?? undefined,
|
||||
funnelWindow: report.funnelWindow ?? undefined,
|
||||
options: options ?? undefined,
|
||||
layout: report.layout ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
783
packages/db/src/services/sankey.service.ts
Normal file
@@ -0,0 +1,783 @@
|
||||
import { chartColors } from '@openpanel/constants';
|
||||
import { type IChartEventFilter, zChartEvent } from '@openpanel/validation';
|
||||
import { z } from 'zod';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import { getEventFiltersWhereClause } from './chart.service';
|
||||
|
||||
export const zGetSankeyInput = z.object({
|
||||
projectId: z.string(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
steps: z.number().min(2).max(10).default(5),
|
||||
mode: z.enum(['between', 'after', 'before']),
|
||||
startEvent: zChartEvent,
|
||||
endEvent: zChartEvent.optional(),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type IGetSankeyInput = z.infer<typeof zGetSankeyInput> & {
|
||||
timezone: string;
|
||||
};
|
||||
|
||||
export class SankeyService {
|
||||
constructor(private client: typeof ch) {}
|
||||
|
||||
getRawWhereClause(type: 'events' | 'sessions', filters: IChartEventFilter[]) {
|
||||
const where = getEventFiltersWhereClause(
|
||||
filters.map((item) => {
|
||||
if (type === 'sessions') {
|
||||
if (item.name === 'path') {
|
||||
return { ...item, name: 'entry_path' };
|
||||
}
|
||||
if (item.name === 'origin') {
|
||||
return { ...item, name: 'entry_origin' };
|
||||
}
|
||||
if (item.name.startsWith('properties.__query.utm_')) {
|
||||
return {
|
||||
...item,
|
||||
name: item.name.replace('properties.__query.utm_', 'utm_'),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
return Object.values(where).join(' AND ');
|
||||
}
|
||||
|
||||
private buildEventNameFilter(
|
||||
include: string[] | undefined,
|
||||
exclude: string[],
|
||||
startEventName: string | undefined,
|
||||
endEventName: string | undefined,
|
||||
) {
|
||||
if (include && include.length > 0) {
|
||||
const eventNames = [...include, startEventName, endEventName]
|
||||
.filter((item) => item !== undefined)
|
||||
.map((e) => `'${e!.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
return `name IN (${eventNames})`;
|
||||
}
|
||||
if (exclude.length > 0) {
|
||||
const excludedNames = exclude
|
||||
.map((e) => `'${e.replace(/'/g, "''")}'`)
|
||||
.join(', ');
|
||||
return `name NOT IN (${excludedNames})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildSessionEventCTE(
|
||||
event: z.infer<typeof zChartEvent>,
|
||||
projectId: string,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
timezone: string,
|
||||
): ReturnType<typeof clix> {
|
||||
return clix(this.client, timezone)
|
||||
.select<{ session_id: string }>(['session_id'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', event.name)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.rawWhere(this.getRawWhereClause('events', event.filters))
|
||||
.groupBy(['session_id']);
|
||||
}
|
||||
|
||||
private getModeConfig(
|
||||
mode: 'after' | 'before' | 'between',
|
||||
startEvent: z.infer<typeof zChartEvent> | undefined,
|
||||
endEvent: z.infer<typeof zChartEvent> | undefined,
|
||||
hasStartEventCTE: boolean,
|
||||
hasEndEventCTE: boolean,
|
||||
steps: number,
|
||||
): { sessionFilter: string; eventsSliceExpr: string } {
|
||||
const defaultSliceExpr = `arraySlice(events_deduped, 1, ${steps})`;
|
||||
|
||||
if (mode === 'after' && startEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const sessionFilter = hasStartEventCTE
|
||||
? 'session_id IN (SELECT session_id FROM start_event_sessions)'
|
||||
: `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`;
|
||||
const eventsSliceExpr = `arraySlice(events_deduped, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped), ${steps})`;
|
||||
return { sessionFilter, eventsSliceExpr };
|
||||
}
|
||||
|
||||
if (mode === 'before' && startEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const sessionFilter = hasStartEventCTE
|
||||
? 'session_id IN (SELECT session_id FROM start_event_sessions)'
|
||||
: `arrayExists(x -> x = '${escapedStartEvent}', events_deduped)`;
|
||||
const eventsSliceExpr = `arraySlice(
|
||||
events_deduped,
|
||||
greatest(1, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - ${steps} + 1),
|
||||
arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - greatest(1, arrayFirstIndex(x -> x = '${escapedStartEvent}', events_deduped) - ${steps} + 1) + 1
|
||||
)`;
|
||||
return { sessionFilter, eventsSliceExpr };
|
||||
}
|
||||
|
||||
if (mode === 'between' && startEvent && endEvent) {
|
||||
const escapedStartEvent = startEvent.name.replace(/'/g, "''");
|
||||
const escapedEndEvent = endEvent.name.replace(/'/g, "''");
|
||||
let sessionFilter = '';
|
||||
if (hasStartEventCTE && hasEndEventCTE) {
|
||||
sessionFilter =
|
||||
'session_id IN (SELECT session_id FROM start_event_sessions) AND session_id IN (SELECT session_id FROM end_event_sessions)';
|
||||
} else if (hasStartEventCTE) {
|
||||
sessionFilter = `session_id IN (SELECT session_id FROM start_event_sessions) AND arrayExists(x -> x = '${escapedEndEvent}', events_deduped)`;
|
||||
} else if (hasEndEventCTE) {
|
||||
sessionFilter = `arrayExists(x -> x = '${escapedStartEvent}', events_deduped) AND session_id IN (SELECT session_id FROM end_event_sessions)`;
|
||||
} else {
|
||||
sessionFilter = `arrayExists(x -> x = '${escapedStartEvent}', events_deduped) AND arrayExists(x -> x = '${escapedEndEvent}', events_deduped)`;
|
||||
}
|
||||
return { sessionFilter, eventsSliceExpr: defaultSliceExpr };
|
||||
}
|
||||
|
||||
return { sessionFilter: '', eventsSliceExpr: defaultSliceExpr };
|
||||
}
|
||||
|
||||
private async executeBetweenMode(
|
||||
sessionPathsQuery: ReturnType<typeof clix>,
|
||||
startEvent: z.infer<typeof zChartEvent>,
|
||||
endEvent: z.infer<typeof zChartEvent>,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
timezone: string,
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
// Find sessions where startEvent comes before endEvent
|
||||
const betweenSessionsQuery = clix(this.client, timezone)
|
||||
.with('session_paths', sessionPathsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events: string[];
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
}>([
|
||||
'session_id',
|
||||
'events',
|
||||
`arrayFirstIndex(x -> x = '${startEvent.name.replace(/'/g, "''")}', events) as start_index`,
|
||||
`arrayFirstIndex(x -> x = '${endEvent.name.replace(/'/g, "''")}', events) as end_index`,
|
||||
])
|
||||
.from('session_paths')
|
||||
.having('start_index', '>', 0)
|
||||
.having('end_index', '>', 0)
|
||||
.rawHaving('start_index < end_index');
|
||||
|
||||
// Get the slice between start and end
|
||||
const betweenPathsQuery = clix(this.client, timezone)
|
||||
.with('between_sessions', betweenSessionsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events: string[];
|
||||
entry_event: string;
|
||||
}>([
|
||||
'session_id',
|
||||
'arraySlice(events, start_index, end_index - start_index + 1) as events',
|
||||
'events[start_index] as entry_event',
|
||||
])
|
||||
.from('between_sessions');
|
||||
|
||||
// Get top entry events
|
||||
const topEntriesQuery = clix(this.client, timezone)
|
||||
.with('session_paths', betweenPathsQuery)
|
||||
.select<{ entry_event: string; count: number }>([
|
||||
'entry_event',
|
||||
'count() as count',
|
||||
])
|
||||
.from('session_paths')
|
||||
.groupBy(['entry_event'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topEntries = await topEntriesQuery.execute();
|
||||
|
||||
if (topEntries.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const topEntryEvents = topEntries.map((e) => e.entry_event);
|
||||
const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
// Get transitions for between mode
|
||||
const transitionsQuery = clix(this.client, timezone)
|
||||
.with('between_sessions', betweenSessionsQuery)
|
||||
.with(
|
||||
'session_paths',
|
||||
clix(this.client, timezone)
|
||||
.select([
|
||||
'session_id',
|
||||
'arraySlice(events, start_index, end_index - start_index + 1) as events',
|
||||
])
|
||||
.from('between_sessions')
|
||||
.having('events[1]', 'IN', topEntryEvents),
|
||||
)
|
||||
.select<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>([
|
||||
'pair.1 as source',
|
||||
'pair.2 as target',
|
||||
'pair.3 as step',
|
||||
'count() as value',
|
||||
])
|
||||
.from(
|
||||
clix.exp(
|
||||
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
|
||||
),
|
||||
)
|
||||
.groupBy(['source', 'target', 'step'])
|
||||
.orderBy('step', 'ASC')
|
||||
.orderBy('value', 'DESC');
|
||||
|
||||
const transitions = await transitionsQuery.execute();
|
||||
|
||||
return this.buildSankeyFromTransitions(
|
||||
transitions,
|
||||
topEntries,
|
||||
totalSessions,
|
||||
steps,
|
||||
COLORS,
|
||||
);
|
||||
}
|
||||
|
||||
private async executeSimpleMode(
|
||||
sessionPathsQuery: ReturnType<typeof clix>,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
timezone: string,
|
||||
): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
// Get top entry events
|
||||
const topEntriesQuery = clix(this.client, timezone)
|
||||
.with('session_paths', sessionPathsQuery)
|
||||
.select<{ entry_event: string; count: number }>([
|
||||
'entry_event',
|
||||
'count() as count',
|
||||
])
|
||||
.from('session_paths')
|
||||
.groupBy(['entry_event'])
|
||||
.orderBy('count', 'DESC')
|
||||
.limit(3);
|
||||
|
||||
const topEntries = await topEntriesQuery.execute();
|
||||
|
||||
if (topEntries.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const topEntryEvents = topEntries.map((e) => e.entry_event);
|
||||
const totalSessions = topEntries.reduce((sum, e) => sum + e.count, 0);
|
||||
|
||||
// Get transitions
|
||||
const transitionsQuery = clix(this.client, timezone)
|
||||
.with('session_paths_base', sessionPathsQuery)
|
||||
.with(
|
||||
'session_paths',
|
||||
clix(this.client, timezone)
|
||||
.select(['session_id', 'events'])
|
||||
.from('session_paths_base')
|
||||
.having('events[1]', 'IN', topEntryEvents),
|
||||
)
|
||||
.select<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>([
|
||||
'pair.1 as source',
|
||||
'pair.2 as target',
|
||||
'pair.3 as step',
|
||||
'count() as value',
|
||||
])
|
||||
.from(
|
||||
clix.exp(
|
||||
'(SELECT arrayJoin(arrayMap(i -> (events[i], events[i + 1], i), range(1, length(events)))) as pair FROM session_paths WHERE length(events) >= 2)',
|
||||
),
|
||||
)
|
||||
.groupBy(['source', 'target', 'step'])
|
||||
.orderBy('step', 'ASC')
|
||||
.orderBy('value', 'DESC');
|
||||
|
||||
const transitions = await transitionsQuery.execute();
|
||||
|
||||
return this.buildSankeyFromTransitions(
|
||||
transitions,
|
||||
topEntries,
|
||||
totalSessions,
|
||||
steps,
|
||||
COLORS,
|
||||
);
|
||||
}
|
||||
|
||||
async getSankey({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
steps = 5,
|
||||
mode,
|
||||
startEvent,
|
||||
endEvent,
|
||||
exclude = [],
|
||||
include,
|
||||
timezone,
|
||||
}: IGetSankeyInput): Promise<{
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
nodeColor: string;
|
||||
percentage?: number;
|
||||
value?: number;
|
||||
step?: number;
|
||||
}>;
|
||||
links: Array<{ source: string; target: string; value: number }>;
|
||||
}> {
|
||||
const COLORS = chartColors.map((color) => color.main);
|
||||
|
||||
// 1. Build event name filter
|
||||
const eventNameFilter = this.buildEventNameFilter(
|
||||
include,
|
||||
exclude,
|
||||
startEvent?.name,
|
||||
endEvent?.name,
|
||||
);
|
||||
|
||||
// 2. Build ordered events query
|
||||
// For screen_view events, use the path instead of the event name for more meaningful flow visualization
|
||||
const orderedEventsQuery = clix(this.client, timezone)
|
||||
.select<{
|
||||
session_id: string;
|
||||
event_name: string;
|
||||
created_at: string;
|
||||
}>([
|
||||
'session_id',
|
||||
// "if(name = 'screen_view', path, name) as event_name",
|
||||
'name as event_name',
|
||||
'created_at',
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate, 'toDateTime'),
|
||||
clix.datetime(endDate, 'toDateTime'),
|
||||
])
|
||||
.orderBy('session_id', 'ASC')
|
||||
.orderBy('created_at', 'ASC');
|
||||
|
||||
if (eventNameFilter) {
|
||||
orderedEventsQuery.rawWhere(eventNameFilter);
|
||||
}
|
||||
|
||||
// 3. Build session event CTEs
|
||||
const startEventCTE = startEvent
|
||||
? this.buildSessionEventCTE(
|
||||
startEvent,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
)
|
||||
: null;
|
||||
const endEventCTE =
|
||||
mode === 'between' && endEvent
|
||||
? this.buildSessionEventCTE(
|
||||
endEvent,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
)
|
||||
: null;
|
||||
|
||||
// 4. Build deduped events CTE
|
||||
const eventsDedupedCTE = clix(this.client, timezone)
|
||||
.with('ordered_events', orderedEventsQuery)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events_deduped: string[];
|
||||
}>([
|
||||
'session_id',
|
||||
`arrayFilter(
|
||||
(x, i) -> i = 1 OR x != events_raw[i - 1],
|
||||
groupArray(event_name) as events_raw,
|
||||
arrayEnumerate(events_raw)
|
||||
) as events_deduped`,
|
||||
])
|
||||
.from('ordered_events')
|
||||
.groupBy(['session_id']);
|
||||
|
||||
// 5. Get mode-specific config
|
||||
const { sessionFilter, eventsSliceExpr } = this.getModeConfig(
|
||||
mode,
|
||||
startEvent,
|
||||
endEvent,
|
||||
startEventCTE !== null,
|
||||
endEventCTE !== null,
|
||||
steps,
|
||||
);
|
||||
|
||||
// 6. Build truncate expression (for 'after' mode)
|
||||
const truncateAtRepeatExpr = `if(
|
||||
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(events_sliced)) = 0,
|
||||
events_sliced,
|
||||
arraySlice(
|
||||
events_sliced,
|
||||
1,
|
||||
arrayFirstIndex(x -> x > 1, arrayEnumerateUniq(events_sliced)) - 1
|
||||
)
|
||||
)`;
|
||||
const eventsExpr =
|
||||
mode === 'before' ? 'events_sliced' : truncateAtRepeatExpr;
|
||||
|
||||
// 7. Build session paths query with conditional CTEs
|
||||
const eventCTEs: Array<{ name: string; query: ReturnType<typeof clix> }> =
|
||||
[];
|
||||
if (startEventCTE) {
|
||||
eventCTEs.push({ name: 'start_event_sessions', query: startEventCTE });
|
||||
}
|
||||
if (endEventCTE) {
|
||||
eventCTEs.push({ name: 'end_event_sessions', query: endEventCTE });
|
||||
}
|
||||
|
||||
const sessionPathsQuery = eventCTEs
|
||||
.reduce(
|
||||
(builder, cte) => builder.with(cte.name, cte.query),
|
||||
clix(this.client, timezone),
|
||||
)
|
||||
.with('events_deduped_cte', eventsDedupedCTE)
|
||||
.with(
|
||||
'events_sliced_cte',
|
||||
clix(this.client, timezone)
|
||||
.select<{
|
||||
session_id: string;
|
||||
events_sliced: string[];
|
||||
}>(['session_id', `${eventsSliceExpr} as events_sliced`])
|
||||
.from('events_deduped_cte')
|
||||
.rawHaving(sessionFilter || '1 = 1'),
|
||||
)
|
||||
.select<{
|
||||
session_id: string;
|
||||
entry_event: string;
|
||||
events: string[];
|
||||
}>(['session_id', `${eventsExpr} as events`, 'events[1] as entry_event'])
|
||||
.from('events_sliced_cte')
|
||||
.having('length(events)', '>=', 2);
|
||||
|
||||
// 8. Execute mode-specific logic
|
||||
if (mode === 'between' && startEvent && endEvent) {
|
||||
return this.executeBetweenMode(
|
||||
sessionPathsQuery,
|
||||
startEvent,
|
||||
endEvent,
|
||||
steps,
|
||||
COLORS,
|
||||
timezone,
|
||||
);
|
||||
}
|
||||
|
||||
return this.executeSimpleMode(sessionPathsQuery, steps, COLORS, timezone);
|
||||
}
|
||||
|
||||
private buildSankeyFromTransitions(
|
||||
transitions: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
step: number;
|
||||
value: number;
|
||||
}>,
|
||||
topEntries: Array<{ entry_event: string; count: number }>,
|
||||
totalSessions: number,
|
||||
steps: number,
|
||||
COLORS: string[],
|
||||
) {
|
||||
if (transitions.length === 0) {
|
||||
return { nodes: [], links: [] };
|
||||
}
|
||||
|
||||
const TOP_DESTINATIONS_PER_NODE = 3;
|
||||
|
||||
// Build the sankey progressively step by step
|
||||
const nodes = new Map<
|
||||
string,
|
||||
{ event: string; value: number; step: number; color: string }
|
||||
>();
|
||||
const links: Array<{ source: string; target: string; value: number }> = [];
|
||||
|
||||
// Helper to create unique node ID
|
||||
const getNodeId = (event: string, step: number) => `${event}::step${step}`;
|
||||
|
||||
// Group transitions by step
|
||||
const transitionsByStep = new Map<number, typeof transitions>();
|
||||
for (const t of transitions) {
|
||||
if (!transitionsByStep.has(t.step)) {
|
||||
transitionsByStep.set(t.step, []);
|
||||
}
|
||||
transitionsByStep.get(t.step)!.push(t);
|
||||
}
|
||||
|
||||
// Initialize with entry events (step 1)
|
||||
const activeNodes = new Map<string, string>(); // event -> nodeId
|
||||
topEntries.forEach((entry, idx) => {
|
||||
const nodeId = getNodeId(entry.entry_event, 1);
|
||||
nodes.set(nodeId, {
|
||||
event: entry.entry_event,
|
||||
value: entry.count,
|
||||
step: 1,
|
||||
color: COLORS[idx % COLORS.length]!,
|
||||
});
|
||||
activeNodes.set(entry.entry_event, nodeId);
|
||||
});
|
||||
|
||||
// Process each step: from active nodes, find top destinations
|
||||
for (let step = 1; step < steps; step++) {
|
||||
const stepTransitions = transitionsByStep.get(step) || [];
|
||||
const nextActiveNodes = new Map<string, string>();
|
||||
|
||||
// For each currently active node, find its top destinations
|
||||
for (const [sourceEvent, sourceNodeId] of activeNodes) {
|
||||
// Get transitions FROM this source event
|
||||
const fromSource = stepTransitions
|
||||
.filter((t) => t.source === sourceEvent)
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, TOP_DESTINATIONS_PER_NODE);
|
||||
|
||||
for (const t of fromSource) {
|
||||
// Skip self-loops
|
||||
if (t.source === t.target) continue;
|
||||
|
||||
const targetNodeId = getNodeId(t.target, step + 1);
|
||||
|
||||
// Add link using unique node IDs
|
||||
links.push({
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
value: t.value,
|
||||
});
|
||||
|
||||
// Add/update target node
|
||||
const existing = nodes.get(targetNodeId);
|
||||
if (existing) {
|
||||
existing.value += t.value;
|
||||
} else {
|
||||
// Inherit color from source or assign new
|
||||
const sourceData = nodes.get(sourceNodeId);
|
||||
nodes.set(targetNodeId, {
|
||||
event: t.target,
|
||||
value: t.value,
|
||||
step: step + 1,
|
||||
color: sourceData?.color || COLORS[nodes.size % COLORS.length]!,
|
||||
});
|
||||
}
|
||||
|
||||
nextActiveNodes.set(t.target, targetNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Update active nodes for next iteration
|
||||
activeNodes.clear();
|
||||
for (const [event, nodeId] of nextActiveNodes) {
|
||||
activeNodes.set(event, nodeId);
|
||||
}
|
||||
|
||||
// Stop if no more nodes to process
|
||||
if (activeNodes.size === 0) break;
|
||||
}
|
||||
|
||||
// Filter links by threshold (0.25% of total sessions)
|
||||
const MIN_LINK_PERCENT = 0.25;
|
||||
const minLinkValue = Math.ceil((totalSessions * MIN_LINK_PERCENT) / 100);
|
||||
const filteredLinks = links.filter((link) => link.value >= minLinkValue);
|
||||
|
||||
// Find all nodes referenced by remaining links
|
||||
const referencedNodeIds = new Set<string>();
|
||||
filteredLinks.forEach((link) => {
|
||||
referencedNodeIds.add(link.source);
|
||||
referencedNodeIds.add(link.target);
|
||||
});
|
||||
|
||||
// Recompute node values from filtered links
|
||||
const nodeValuesFromLinks = new Map<string, number>();
|
||||
filteredLinks.forEach((link) => {
|
||||
const current = nodeValuesFromLinks.get(link.target) || 0;
|
||||
nodeValuesFromLinks.set(link.target, current + link.value);
|
||||
});
|
||||
|
||||
// For entry nodes (step 1), only keep them if they have outgoing links after filtering
|
||||
nodes.forEach((nodeData, nodeId) => {
|
||||
if (nodeData.step === 1) {
|
||||
const hasOutgoing = filteredLinks.some((l) => l.source === nodeId);
|
||||
if (!hasOutgoing) {
|
||||
referencedNodeIds.delete(nodeId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build final nodes array sorted by step then value
|
||||
const finalNodes = Array.from(nodes.entries())
|
||||
.filter(([id]) => referencedNodeIds.has(id))
|
||||
.map(([id, data]) => {
|
||||
const value =
|
||||
data.step === 1
|
||||
? data.value
|
||||
: nodeValuesFromLinks.get(id) || data.value;
|
||||
return {
|
||||
id,
|
||||
label: data.event,
|
||||
nodeColor: data.color,
|
||||
percentage: (value / totalSessions) * 100,
|
||||
value,
|
||||
step: data.step,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.step !== b.step) return a.step - b.step;
|
||||
return b.value - a.value;
|
||||
});
|
||||
|
||||
// Sanity check: Ensure all link endpoints exist in nodes
|
||||
const nodeIds = new Set(finalNodes.map((n) => n.id));
|
||||
const validLinks = filteredLinks.filter(
|
||||
(link) => nodeIds.has(link.source) && nodeIds.has(link.target),
|
||||
);
|
||||
|
||||
// Combine final nodes with the same event name
|
||||
// A final node is one that has no outgoing links
|
||||
const nodesWithOutgoing = new Set(validLinks.map((l) => l.source));
|
||||
const finalNodeIds = new Set(
|
||||
finalNodes.filter((n) => !nodesWithOutgoing.has(n.id)).map((n) => n.id),
|
||||
);
|
||||
|
||||
// Group final nodes by event name
|
||||
const finalNodesByEvent = new Map<string, typeof finalNodes>();
|
||||
finalNodes.forEach((node) => {
|
||||
if (finalNodeIds.has(node.id)) {
|
||||
if (!finalNodesByEvent.has(node.label)) {
|
||||
finalNodesByEvent.set(node.label, []);
|
||||
}
|
||||
finalNodesByEvent.get(node.label)!.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Create merged nodes and remap links
|
||||
const nodeIdRemap = new Map<string, string>(); // old nodeId -> new merged nodeId
|
||||
const mergedNodes = new Map<string, (typeof finalNodes)[0]>(); // merged nodeId -> node data
|
||||
|
||||
finalNodesByEvent.forEach((nodesToMerge, eventName) => {
|
||||
if (nodesToMerge.length > 1) {
|
||||
// Merge multiple final nodes with same event name
|
||||
const maxStep = Math.max(...nodesToMerge.map((n) => n.step || 0));
|
||||
const totalValue = nodesToMerge.reduce(
|
||||
(sum, n) => sum + (n.value || 0),
|
||||
0,
|
||||
);
|
||||
const mergedNodeId = `${eventName}::final`;
|
||||
const firstNode = nodesToMerge[0]!;
|
||||
|
||||
// Create merged node at the maximum step
|
||||
mergedNodes.set(mergedNodeId, {
|
||||
id: mergedNodeId,
|
||||
label: eventName,
|
||||
nodeColor: firstNode.nodeColor,
|
||||
percentage: (totalValue / totalSessions) * 100,
|
||||
value: totalValue,
|
||||
step: maxStep,
|
||||
});
|
||||
|
||||
// Map all old node IDs to the merged node ID
|
||||
nodesToMerge.forEach((node) => {
|
||||
nodeIdRemap.set(node.id, mergedNodeId);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Update links to point to merged nodes
|
||||
const remappedLinks = validLinks.map((link) => {
|
||||
const newSource = nodeIdRemap.get(link.source) || link.source;
|
||||
const newTarget = nodeIdRemap.get(link.target) || link.target;
|
||||
return {
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
value: link.value,
|
||||
};
|
||||
});
|
||||
|
||||
// Combine merged nodes with non-final nodes
|
||||
const nonFinalNodes = finalNodes.filter((n) => !finalNodeIds.has(n.id));
|
||||
const finalNodesList = Array.from(mergedNodes.values());
|
||||
|
||||
// Remove old final nodes that were merged
|
||||
const mergedOldNodeIds = new Set(nodeIdRemap.keys());
|
||||
const remainingNodes = nonFinalNodes.filter(
|
||||
(n) => !mergedOldNodeIds.has(n.id),
|
||||
);
|
||||
|
||||
// Combine all nodes and sort
|
||||
const allNodes = [...remainingNodes, ...finalNodesList].sort((a, b) => {
|
||||
if (a.step !== b.step) return a.step! - b.step!;
|
||||
return b.value! - a.value!;
|
||||
});
|
||||
|
||||
// Aggregate links that now point to the same merged target
|
||||
const linkMap = new Map<string, number>(); // "source->target" -> value
|
||||
remappedLinks.forEach((link) => {
|
||||
const key = `${link.source}->${link.target}`;
|
||||
linkMap.set(key, (linkMap.get(key) || 0) + link.value);
|
||||
});
|
||||
|
||||
const aggregatedLinks = Array.from(linkMap.entries())
|
||||
.map(([key, value]) => {
|
||||
const parts = key.split('->');
|
||||
if (parts.length !== 2) return null;
|
||||
return { source: parts[0]!, target: parts[1]!, value };
|
||||
})
|
||||
.filter(
|
||||
(link): link is { source: string; target: string; value: number } =>
|
||||
link !== null,
|
||||
);
|
||||
|
||||
// Final sanity check: Ensure all link endpoints exist in nodes
|
||||
const finalNodeIdsSet = new Set(allNodes.map((n) => n.id));
|
||||
const finalValidLinks: Array<{
|
||||
source: string;
|
||||
target: string;
|
||||
value: number;
|
||||
}> = aggregatedLinks.filter(
|
||||
(link) =>
|
||||
finalNodeIdsSet.has(link.source) && finalNodeIdsSet.has(link.target),
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: allNodes,
|
||||
links: finalValidLinks,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const sankeyService = new SankeyService(ch);
|
||||
@@ -22,11 +22,10 @@ import {
|
||||
getSelectPropertyKey,
|
||||
getSettingsForProject,
|
||||
onlyReportEvents,
|
||||
sankeyService,
|
||||
} from '@openpanel/db';
|
||||
import {
|
||||
type IChartEvent,
|
||||
zChartEvent,
|
||||
zChartEventFilter,
|
||||
zChartInput,
|
||||
zChartSeries,
|
||||
zCriteria,
|
||||
@@ -379,6 +378,38 @@ export const chartRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
sankey: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const { timezone } = await getSettingsForProject(input.projectId);
|
||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
||||
|
||||
// Extract sankey options
|
||||
const options = input.options;
|
||||
|
||||
if (!options || options.type !== 'sankey') {
|
||||
throw new Error('Sankey options are required');
|
||||
}
|
||||
|
||||
// Extract start/end events from series based on mode
|
||||
const eventSeries = onlyReportEvents(input.series);
|
||||
|
||||
if (!eventSeries[0]) {
|
||||
throw new Error('Start and end events are required');
|
||||
}
|
||||
|
||||
return sankeyService.getSankey({
|
||||
projectId: input.projectId,
|
||||
startDate: currentPeriod.startDate,
|
||||
endDate: currentPeriod.endDate,
|
||||
steps: options.steps,
|
||||
mode: options.mode,
|
||||
startEvent: eventSeries[0],
|
||||
endEvent: eventSeries[1],
|
||||
exclude: options.exclude || [],
|
||||
include: options.include,
|
||||
timezone,
|
||||
});
|
||||
}),
|
||||
|
||||
chart: publicProcedure
|
||||
// .use(cacher)
|
||||
.input(zChartInput)
|
||||
|
||||
@@ -59,6 +59,7 @@ export const reportRouter = createTRPCRouter({
|
||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -104,6 +105,7 @@ export const reportRouter = createTRPCRouter({
|
||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
@@ -175,6 +177,7 @@ export const reportRouter = createTRPCRouter({
|
||||
metric: report.metric,
|
||||
funnelGroup: report.funnelGroup,
|
||||
funnelWindow: report.funnelWindow,
|
||||
options: report.options,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -88,36 +88,11 @@ export const zChartBreakdown = z.object({
|
||||
|
||||
// Support both old format (array of events without type) and new format (array of event/formula items)
|
||||
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
|
||||
export const zChartSeries = z.preprocess((val) => {
|
||||
if (!val) return val;
|
||||
let processedVal = val;
|
||||
|
||||
// If the input is an object with numeric keys, convert it to an array
|
||||
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
||||
const keys = Object.keys(val).sort(
|
||||
(a, b) => Number.parseInt(a) - Number.parseInt(b),
|
||||
);
|
||||
processedVal = keys.map((key) => (val as any)[key]);
|
||||
}
|
||||
|
||||
if (!Array.isArray(processedVal)) return processedVal;
|
||||
|
||||
return processedVal.map((item: any) => {
|
||||
// If item already has type field, return as-is
|
||||
if (item && typeof item === 'object' && 'type' in item) {
|
||||
return item;
|
||||
}
|
||||
// Otherwise, add type: 'event' for backward compatibility
|
||||
if (item && typeof item === 'object' && 'name' in item) {
|
||||
return { ...item, type: 'event' };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}, z
|
||||
export const zChartSeries = z
|
||||
.array(zChartEventItem)
|
||||
.describe(
|
||||
'Array of series (events or formulas) to be tracked and displayed in the chart',
|
||||
));
|
||||
);
|
||||
|
||||
// Keep zChartEvents as an alias for backward compatibility during migration
|
||||
export const zChartEvents = zChartSeries;
|
||||
@@ -135,6 +110,35 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
|
||||
|
||||
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||
|
||||
// Report Options - Discriminated union based on chart type
|
||||
export const zFunnelOptions = z.object({
|
||||
type: z.literal('funnel'),
|
||||
funnelGroup: z.string().optional(),
|
||||
funnelWindow: z.number().optional(),
|
||||
});
|
||||
|
||||
export const zRetentionOptions = z.object({
|
||||
type: z.literal('retention'),
|
||||
criteria: zCriteria.optional(),
|
||||
});
|
||||
|
||||
export const zSankeyOptions = z.object({
|
||||
type: z.literal('sankey'),
|
||||
mode: z.enum(['between', 'after', 'before']),
|
||||
steps: z.number().min(2).max(10).default(5),
|
||||
exclude: z.array(z.string()).default([]),
|
||||
include: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const zReportOptions = z.discriminatedUnion('type', [
|
||||
zFunnelOptions,
|
||||
zRetentionOptions,
|
||||
zSankeyOptions,
|
||||
]);
|
||||
|
||||
export type IReportOptions = z.infer<typeof zReportOptions>;
|
||||
export type ISankeyOptions = z.infer<typeof zSankeyOptions>;
|
||||
|
||||
export const zChartInputBase = z.object({
|
||||
chartType: zChartType
|
||||
.default('linear')
|
||||
@@ -200,15 +204,10 @@ export const zChartInputBase = z.object({
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Time window in hours for funnel analysis'),
|
||||
options: zReportOptions.optional(),
|
||||
});
|
||||
|
||||
export const zChartInput = z.preprocess((val) => {
|
||||
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
|
||||
// Migrate old 'events' field to 'series'
|
||||
return { ...val, series: val.events };
|
||||
}
|
||||
return val;
|
||||
}, zChartInputBase);
|
||||
export const zChartInput = zChartInputBase;
|
||||
|
||||
export const zReportInput = zChartInputBase.extend({
|
||||
name: z.string().describe('The user-defined name for the report'),
|
||||
|
||||