feat: share dashboard & reports, sankey report, new widgets
* fix: prompt card shadows on light mode * fix: handle past_due and unpaid from polar * wip * wip * wip 1 * fix: improve types for chart/reports * wip share
@@ -13,7 +13,7 @@ import {
|
|||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
|
||||||
async function getProjectId(
|
async function getProjectId(
|
||||||
@@ -139,7 +139,7 @@ export async function events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartSchemeFull = zChartInputBase
|
const chartSchemeFull = zReport
|
||||||
.pick({
|
.pick({
|
||||||
breakdowns: true,
|
breakdowns: true,
|
||||||
interval: true,
|
interval: true,
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ export async function polarWebhook(
|
|||||||
where: {
|
where: {
|
||||||
subscriptionCustomerId: event.data.customer.id,
|
subscriptionCustomerId: event.data.customer.id,
|
||||||
subscriptionId: event.data.id,
|
subscriptionId: event.data.id,
|
||||||
subscriptionStatus: 'active',
|
subscriptionStatus: {
|
||||||
|
in: ['active', 'past_due', 'unpaid'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { zChartInputAI } from '@openpanel/validation';
|
import { zReportInput } from '@openpanel/validation';
|
||||||
import { tool } from 'ai';
|
import { tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@ export function getReport({
|
|||||||
- ${chartTypes.metric}
|
- ${chartTypes.metric}
|
||||||
- ${chartTypes.bar}
|
- ${chartTypes.bar}
|
||||||
`,
|
`,
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
@@ -72,7 +75,10 @@ export function getConversionReport({
|
|||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
@@ -94,7 +100,10 @@ export function getFunnelReport({
|
|||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
|
|||||||
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": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
"cf-typegen": "wrangler types",
|
"cf-typegen": "wrangler types",
|
||||||
"build": "pnpm with-env vite build",
|
"build": "pnpm with-env vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "vitest run",
|
|
||||||
"format": "biome format",
|
"format": "biome format",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"check": "biome check",
|
"check": "biome check",
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
"@hookform/resolvers": "^3.3.4",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@hyperdx/node-opentelemetry": "^0.8.1",
|
"@hyperdx/node-opentelemetry": "^0.8.1",
|
||||||
"@nivo/sankey": "^0.99.0",
|
"@nivo/sankey": "^0.99.0",
|
||||||
"@number-flow/react": "0.3.5",
|
"@number-flow/react": "0.5.10",
|
||||||
"@openpanel/common": "workspace:^",
|
"@openpanel/common": "workspace:^",
|
||||||
"@openpanel/constants": "workspace:^",
|
"@openpanel/constants": "workspace:^",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
@@ -150,7 +149,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "1.9.4",
|
||||||
"@cloudflare/vite-plugin": "^1.13.12",
|
"@cloudflare/vite-plugin": "1.20.3",
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/trpc": "workspace:*",
|
"@openpanel/trpc": "workspace:*",
|
||||||
"@tanstack/devtools-event-client": "^0.3.3",
|
"@tanstack/devtools-event-client": "^0.3.3",
|
||||||
@@ -171,6 +170,6 @@
|
|||||||
"vite": "^6.3.5",
|
"vite": "^6.3.5",
|
||||||
"vitest": "^3.0.5",
|
"vitest": "^3.0.5",
|
||||||
"web-vitals": "^4.2.4",
|
"web-vitals": "^4.2.4",
|
||||||
"wrangler": "^4.42.2"
|
"wrangler": "4.59.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,6 @@
|
|||||||
import type { NumberFlowProps } from '@number-flow/react';
|
import type { NumberFlowProps } from '@number-flow/react';
|
||||||
import { useEffect, useState } from 'react';
|
import ReactAnimatedNumber from '@number-flow/react';
|
||||||
|
|
||||||
// NumberFlow is breaking ssr and forces loaders to fetch twice
|
|
||||||
export function AnimatedNumber(props: NumberFlowProps) {
|
export function AnimatedNumber(props: NumberFlowProps) {
|
||||||
const [Component, setComponent] =
|
return <ReactAnimatedNumber {...props} />;
|
||||||
useState<React.ComponentType<NumberFlowProps> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
import('@number-flow/react').then(({ default: NumberFlow }) => {
|
|
||||||
setComponent(NumberFlow);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!Component) {
|
|
||||||
return <>{props.value}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Component {...props} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { LogoSquare } from '../logo';
|
|||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
|
|
||||||
export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
export function ShareEnterPassword({
|
||||||
|
shareId,
|
||||||
|
shareType = 'overview',
|
||||||
|
}: {
|
||||||
|
shareId: string;
|
||||||
|
shareType?: 'overview' | 'dashboard' | 'report';
|
||||||
|
}) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.auth.signInShare.mutationOptions({
|
trpc.auth.signInShare.mutationOptions({
|
||||||
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
password: '',
|
password: '',
|
||||||
shareId,
|
shareId,
|
||||||
|
shareType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
|||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
password: data.password,
|
password: data.password,
|
||||||
shareId,
|
shareId,
|
||||||
|
shareType,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
|
|||||||
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
|
||||||
<div className="col mt-1 flex-1 gap-2">
|
<div className="col mt-1 flex-1 gap-2">
|
||||||
<LogoSquare className="size-12 mb-4" />
|
<LogoSquare className="size-12 mb-4" />
|
||||||
<div className="text-xl font-semibold">Overview is locked</div>
|
<div className="text-xl font-semibold">
|
||||||
|
{shareType === 'dashboard'
|
||||||
|
? 'Dashboard is locked'
|
||||||
|
: shareType === 'report'
|
||||||
|
? 'Report is locked'
|
||||||
|
: 'Overview is locked'}
|
||||||
|
</div>
|
||||||
<div className="text-lg text-muted-foreground leading-normal">
|
<div className="text-lg text-muted-foreground leading-normal">
|
||||||
Please enter correct password to access this overview
|
Please enter correct password to access this{' '}
|
||||||
|
{shareType === 'dashboard'
|
||||||
|
? 'dashboard'
|
||||||
|
: shareType === 'report'
|
||||||
|
? 'report'
|
||||||
|
: 'overview'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
<form onSubmit={onSubmit} className="col gap-4 mt-6">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Markdown } from '@/components/markdown';
|
import { Markdown } from '@/components/markdown';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { zChartInputAI } from '@openpanel/validation';
|
import { zReport } from '@openpanel/validation';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||||
import { Fragment, memo } from 'react';
|
import { Fragment, memo } from 'react';
|
||||||
@@ -77,7 +78,10 @@ export const ChatMessage = memo(
|
|||||||
const { result } = p.toolInvocation;
|
const { result } = p.toolInvocation;
|
||||||
|
|
||||||
if (result.type === 'report') {
|
if (result.type === 'report') {
|
||||||
const report = zChartInputAI.safeParse(result.report);
|
const report = zReport.extend({
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
}).safeParse(result.report);
|
||||||
if (report.success) {
|
if (report.success) {
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type {
|
import type {
|
||||||
IChartInputAi,
|
IReport,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
|
|||||||
export function ChatReport({
|
export function ChatReport({
|
||||||
lazy,
|
lazy,
|
||||||
...props
|
...props
|
||||||
}: { report: IChartInputAi; lazy: boolean }) {
|
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||||
const [chartType, setChartType] = useState<IChartType>(
|
const [chartType, setChartType] = useState<IChartType>(
|
||||||
props.report.chartType,
|
props.report.chartType,
|
||||||
);
|
);
|
||||||
|
|||||||
95
apps/start/src/components/grafana-grid.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { IServiceReport } from '@openpanel/db';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Responsive, WidthProvider } from 'react-grid-layout';
|
||||||
|
|
||||||
|
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||||
|
|
||||||
|
export type Layout = ReactGridLayout.Layout;
|
||||||
|
|
||||||
|
export const useReportLayouts = (
|
||||||
|
reports: NonNullable<IServiceReport>[],
|
||||||
|
): ReactGridLayout.Layouts => {
|
||||||
|
return useMemo(() => {
|
||||||
|
const baseLayout = reports.map((report, index) => ({
|
||||||
|
i: report.id,
|
||||||
|
x: report.layout?.x ?? (index % 2) * 6,
|
||||||
|
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
||||||
|
w: report.layout?.w ?? 6,
|
||||||
|
h: report.layout?.h ?? 4,
|
||||||
|
minW: 3,
|
||||||
|
minH: 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
lg: baseLayout,
|
||||||
|
md: baseLayout,
|
||||||
|
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
||||||
|
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
||||||
|
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
||||||
|
};
|
||||||
|
}, [reports]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GrafanaGrid({
|
||||||
|
layouts,
|
||||||
|
children,
|
||||||
|
transitions,
|
||||||
|
onLayoutChange,
|
||||||
|
onDragStop,
|
||||||
|
onResizeStop,
|
||||||
|
isDraggable,
|
||||||
|
isResizable,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
transitions?: boolean;
|
||||||
|
} & Pick<
|
||||||
|
ReactGridLayout.ResponsiveProps,
|
||||||
|
| 'layouts'
|
||||||
|
| 'onLayoutChange'
|
||||||
|
| 'onDragStop'
|
||||||
|
| 'onResizeStop'
|
||||||
|
| 'isDraggable'
|
||||||
|
| 'isResizable'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
.react-grid-item {
|
||||||
|
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
||||||
|
}
|
||||||
|
.react-grid-item.react-grid-placeholder {
|
||||||
|
background: none !important;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px dashed var(--primary);
|
||||||
|
}
|
||||||
|
.react-grid-item.resizing {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="-m-4">
|
||||||
|
<ResponsiveGridLayout
|
||||||
|
className="layout"
|
||||||
|
layouts={layouts}
|
||||||
|
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
||||||
|
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
||||||
|
rowHeight={100}
|
||||||
|
draggableHandle=".drag-handle"
|
||||||
|
compactType="vertical"
|
||||||
|
preventCollision={false}
|
||||||
|
margin={[16, 16]}
|
||||||
|
transformScale={1}
|
||||||
|
useCSSTransforms={true}
|
||||||
|
onLayoutChange={onLayoutChange}
|
||||||
|
onDragStop={onDragStop}
|
||||||
|
onResizeStop={onResizeStop}
|
||||||
|
isDraggable={isDraggable}
|
||||||
|
isResizable={isResizable}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://openpanel.dev/compare/mixpanel-alternative">
|
<a href="https://openpanel.dev/compare/posthog-alternative">
|
||||||
Posthog alternative
|
Posthog alternative
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function PromptCard({
|
|||||||
}}
|
}}
|
||||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
|
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
|
||||||
>
|
>
|
||||||
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_rgba(20,20,20,1)] col gap-6 py-6 overflow-hidden">
|
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
|
||||||
<div className="relative px-6 col gap-1">
|
<div className="relative px-6 col gap-1">
|
||||||
<div
|
<div
|
||||||
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const report: IChartInput = useMemo(
|
const report: IReportInput = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -96,9 +96,7 @@ export default function OverviewTopEvents({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
chartType: 'bar' as const,
|
chartType: 'bar' as const,
|
||||||
lineType: 'monotone' as const,
|
|
||||||
interval,
|
interval,
|
||||||
name: widget.title,
|
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
metric: 'sum' as const,
|
metric: 'sum' as const,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import { ReportChart } from '../report-chart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
|
import { ReportChartShortcut } from '../report-chart/shortcut';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -210,9 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
<div className="title">Map</div>
|
<div className="title">Map</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChart
|
<ReportChartShortcut
|
||||||
options={{ hideID: true }}
|
{...{
|
||||||
report={{
|
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -232,12 +232,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
chartType: 'map',
|
chartType: 'map',
|
||||||
lineType: 'monotone',
|
|
||||||
interval: interval,
|
interval: interval,
|
||||||
name: 'Top sources',
|
|
||||||
range: range,
|
range: range,
|
||||||
previous: previous,
|
previous: previous,
|
||||||
metric: 'sum',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
|
|||||||
import { Widget, WidgetBody } from '@/components/widget';
|
import { Widget, WidgetBody } from '@/components/widget';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
import { WidgetHead } from '../overview/overview-widget';
|
import { WidgetHead } from '../overview/overview-widget';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
export const ProfileCharts = memo(
|
export const ProfileCharts = memo(
|
||||||
({ profileId, projectId }: Props) => {
|
({ profileId, projectId }: Props) => {
|
||||||
const pageViewsChart: IChartProps = {
|
const pageViewsChart: IReport = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
series: [
|
series: [
|
||||||
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventsChart: IChartProps = {
|
const eventsChart: IReport = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
series: [
|
series: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportAreaChart() {
|
export function ReportAreaChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.chart.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportBarChart() {
|
export function ReportBarChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(report, {
|
trpc.chart.aggregate.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
|
|||||||
className,
|
className,
|
||||||
}: PreviousDiffIndicatorProps) {
|
}: PreviousDiffIndicatorProps) {
|
||||||
const {
|
const {
|
||||||
report: { previousIndicatorInverted, previous },
|
report: { previous },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const variant = getDiffIndicator(
|
const variant = getDiffIndicator(
|
||||||
inverted ?? previousIndicatorInverted,
|
inverted,
|
||||||
state,
|
state,
|
||||||
'bg-emerald-300',
|
'bg-emerald-300',
|
||||||
'bg-rose-300',
|
'bg-rose-300',
|
||||||
|
|||||||
@@ -2,16 +2,11 @@ import isEqual from 'lodash.isequal';
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type { IChartSerie, IReportInput } from '@openpanel/validation';
|
||||||
IChartInput,
|
|
||||||
IChartProps,
|
|
||||||
IChartSerie,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
|
|
||||||
export type ReportChartContextType = {
|
export type ReportChartContextType = {
|
||||||
options: Partial<{
|
options: Partial<{
|
||||||
columns: React.ReactNode[];
|
columns: React.ReactNode[];
|
||||||
hideID: boolean;
|
|
||||||
hideLegend: boolean;
|
hideLegend: boolean;
|
||||||
hideXAxis: boolean;
|
hideXAxis: boolean;
|
||||||
hideYAxis: boolean;
|
hideYAxis: boolean;
|
||||||
@@ -28,9 +23,11 @@ export type ReportChartContextType = {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}[];
|
}[];
|
||||||
}>;
|
}>;
|
||||||
report: IChartProps;
|
report: IReportInput & { id?: string };
|
||||||
isLazyLoading: boolean;
|
isLazyLoading: boolean;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
|
shareId?: string;
|
||||||
|
reportId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReportChartContextProviderProps = ReportChartContextType & {
|
type ReportChartContextProviderProps = ReportChartContextType & {
|
||||||
@@ -38,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||||
report: IChartInput;
|
report: IReportInput & { id?: string };
|
||||||
lazy?: boolean;
|
lazy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,20 +51,6 @@ export const useReportChartContext = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSelectReportChartContext = <T,>(
|
|
||||||
selector: (ctx: ReportChartContextType) => T,
|
|
||||||
) => {
|
|
||||||
const ctx = useReportChartContext();
|
|
||||||
const [state, setState] = useState(selector(ctx));
|
|
||||||
useEffect(() => {
|
|
||||||
const newState = selector(ctx);
|
|
||||||
if (!isEqual(newState, state)) {
|
|
||||||
setState(newState);
|
|
||||||
}
|
|
||||||
}, [ctx]);
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ReportChartProvider = ({
|
export const ReportChartProvider = ({
|
||||||
children,
|
children,
|
||||||
...propsToContext
|
...propsToContext
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -11,15 +12,27 @@ import { Chart } from './chart';
|
|||||||
import { Summary } from './summary';
|
import { Summary } from './summary';
|
||||||
|
|
||||||
export function ReportConversionChart() {
|
export function ReportConversionChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
console.log(report.limit);
|
console.log(report.limit);
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.conversion.queryOptions(report, {
|
trpc.chart.conversion.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -131,34 +131,36 @@ export function Tables({
|
|||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns: reportBreakdowns,
|
breakdowns: reportBreakdowns,
|
||||||
previous,
|
previous,
|
||||||
funnelWindow,
|
options,
|
||||||
funnelGroup,
|
|
||||||
},
|
},
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
|
||||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||||
if (!projectId || !step.event.id) return;
|
if (!projectId || !step.event.id) return;
|
||||||
|
|
||||||
// For funnels, we need to pass the step index so the modal can query
|
// For funnels, we need to pass the step index so the modal can query
|
||||||
// users who completed at least that step in the funnel sequence
|
// users who completed at least that step in the funnel sequence
|
||||||
pushModal('ViewChartUsers', {
|
pushModal('ViewChartUsers', {
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
report: {
|
report: {
|
||||||
projectId,
|
projectId,
|
||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns: reportBreakdowns || [],
|
breakdowns: reportBreakdowns || [],
|
||||||
interval: interval || 'day',
|
interval: interval || 'day',
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
funnelWindow,
|
options: funnelOptions,
|
||||||
funnelGroup,
|
},
|
||||||
},
|
stepIndex, // Pass the step index for funnel queries
|
||||||
stepIndex, // Pass the step index for funnel queries
|
});
|
||||||
});
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className={cn('col @container divide-y divide-border card')}>
|
<div className={cn('col @container divide-y divide-border card')}>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useTRPC } from '@/integrations/trpc/react';
|
|||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -14,35 +15,39 @@ import { Chart, Summary, Tables } from './chart';
|
|||||||
export function ReportFunnelChart() {
|
export function ReportFunnelChart() {
|
||||||
const {
|
const {
|
||||||
report: {
|
report: {
|
||||||
|
id,
|
||||||
series,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
funnelWindow,
|
options,
|
||||||
funnelGroup,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
previous,
|
previous,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
|
interval,
|
||||||
},
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
|
shareId,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
||||||
|
|
||||||
const input: IChartInput = {
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const input: IReportInput = {
|
||||||
series,
|
series,
|
||||||
range,
|
range: overviewRange ?? range,
|
||||||
projectId,
|
projectId,
|
||||||
interval: 'day',
|
interval: overviewInterval ?? interval ?? 'day',
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
breakdowns,
|
breakdowns,
|
||||||
funnelWindow,
|
|
||||||
funnelGroup,
|
|
||||||
previous,
|
previous,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
startDate,
|
startDate: overviewStartDate ?? startDate,
|
||||||
endDate,
|
endDate: overviewEndDate ?? endDate,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
options: funnelOptions,
|
||||||
};
|
};
|
||||||
const trpc = useTRPC();
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.funnel.queryOptions(input, {
|
trpc.chart.funnel.queryOptions(input, {
|
||||||
enabled: !isLazyLoading && input.series.length > 0,
|
enabled: !isLazyLoading && input.series.length > 0,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportHistogramChart() {
|
export function ReportHistogramChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.chart.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
|
|||||||
import { ReportMetricChart } from './metric';
|
import { ReportMetricChart } from './metric';
|
||||||
import { ReportPieChart } from './pie';
|
import { ReportPieChart } from './pie';
|
||||||
import { ReportRetentionChart } from './retention';
|
import { ReportRetentionChart } from './retention';
|
||||||
|
import { ReportSankeyChart } from './sankey';
|
||||||
|
|
||||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
|||||||
return <ReportRetentionChart />;
|
return <ReportRetentionChart />;
|
||||||
case 'conversion':
|
case 'conversion':
|
||||||
return <ReportConversionChart />;
|
return <ReportConversionChart />;
|
||||||
|
case 'sankey':
|
||||||
|
return <ReportSankeyChart />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
|
|||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -10,15 +11,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportLineChart() {
|
export function ReportLineChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.chart.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportMapChart() {
|
export function ReportMapChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.chart.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -8,15 +9,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportMetricChart() {
|
export function ReportMetricChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.chart.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -54,10 +54,7 @@ export function MetricCard({
|
|||||||
metric,
|
metric,
|
||||||
unit,
|
unit,
|
||||||
}: MetricCardProps) {
|
}: MetricCardProps) {
|
||||||
const {
|
const { isEditMode } = useReportChartContext();
|
||||||
report: { previousIndicatorInverted },
|
|
||||||
isEditMode,
|
|
||||||
} = useReportChartContext();
|
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
|
||||||
const renderValue = (value: number | undefined, unitClassName?: string) => {
|
const renderValue = (value: number | undefined, unitClassName?: string) => {
|
||||||
@@ -80,7 +77,7 @@ export function MetricCard({
|
|||||||
const previous = serie.metrics.previous?.[metric];
|
const previous = serie.metrics.previous?.[metric];
|
||||||
|
|
||||||
const graphColors = getDiffIndicator(
|
const graphColors = getDiffIndicator(
|
||||||
previousIndicatorInverted,
|
false,
|
||||||
previous?.state,
|
previous?.state,
|
||||||
'#6ee7b7', // green
|
'#6ee7b7', // green
|
||||||
'#fda4af', // red
|
'#fda4af', // red
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
|
|||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
export function ReportPieChart() {
|
export function ReportPieChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(report, {
|
trpc.chart.aggregate.queryOptions(
|
||||||
placeholderData: keepPreviousData,
|
{
|
||||||
staleTime: 1000 * 60 * 1,
|
...report,
|
||||||
enabled: !isLazyLoading,
|
shareId,
|
||||||
}),
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
changeStartDate,
|
changeStartDate,
|
||||||
ready,
|
ready,
|
||||||
reset,
|
reset,
|
||||||
setName,
|
|
||||||
setReport,
|
setReport,
|
||||||
} from '@/components/report/reportSlice';
|
} from '@/components/report/reportSlice';
|
||||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||||
@@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { bind } from 'bind-event-listener';
|
import { bind } from 'bind-event-listener';
|
||||||
import { GanttChartSquareIcon } from 'lucide-react';
|
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import type { IServiceReport } from '@openpanel/db';
|
import type { IServiceReport } from '@openpanel/db';
|
||||||
@@ -54,8 +54,19 @@ export default function ReportEditor({
|
|||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<div>
|
<div>
|
||||||
<div className="p-4">
|
<div className="p-4 flex items-center justify-between">
|
||||||
<EditReportName />
|
<EditReportName />
|
||||||
|
{initialReport?.id && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon={ShareIcon}
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('ShareReportModal', { reportId: initialReport.id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -12,21 +13,33 @@ import CohortTable from './table';
|
|||||||
export function ReportRetentionChart() {
|
export function ReportRetentionChart() {
|
||||||
const {
|
const {
|
||||||
report: {
|
report: {
|
||||||
|
id,
|
||||||
series,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
|
options,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
criteria,
|
|
||||||
interval,
|
interval,
|
||||||
},
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
|
shareId,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
const {
|
||||||
|
range: overviewRange,
|
||||||
|
startDate: overviewStartDate,
|
||||||
|
endDate: overviewEndDate,
|
||||||
|
interval: overviewInterval,
|
||||||
|
} = useOverviewOptions();
|
||||||
const eventSeries = series.filter((item) => item.type === 'event');
|
const eventSeries = series.filter((item) => item.type === 'event');
|
||||||
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||||
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
||||||
const isEnabled =
|
const isEnabled =
|
||||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||||
|
|
||||||
|
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||||
|
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||||
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.cohort.queryOptions(
|
trpc.chart.cohort.queryOptions(
|
||||||
@@ -34,11 +47,13 @@ export function ReportRetentionChart() {
|
|||||||
firstEvent,
|
firstEvent,
|
||||||
secondEvent,
|
secondEvent,
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
range: overviewRange ?? range,
|
||||||
startDate,
|
startDate: overviewStartDate ?? startDate,
|
||||||
endDate,
|
endDate: overviewEndDate ?? endDate,
|
||||||
criteria,
|
criteria,
|
||||||
interval,
|
interval: overviewInterval ?? interval,
|
||||||
|
shareId,
|
||||||
|
reportId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
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 { IReportInput } 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: IReportInput = {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
|
|||||||
return (
|
return (
|
||||||
<ReportChart
|
<ReportChart
|
||||||
report={{
|
report={{
|
||||||
name: 'Shortcut',
|
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
range,
|
||||||
breakdowns: breakdowns ?? [],
|
breakdowns: breakdowns ?? [],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChartColumnIncreasingIcon,
|
ChartColumnIncreasingIcon,
|
||||||
ConeIcon,
|
ConeIcon,
|
||||||
GaugeIcon,
|
GaugeIcon,
|
||||||
|
GitBranchIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
LineChartIcon,
|
LineChartIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -58,6 +59,7 @@ export function ReportChartType({
|
|||||||
retention: UsersIcon,
|
retention: UsersIcon,
|
||||||
map: Globe2Icon,
|
map: Globe2Icon,
|
||||||
conversion: TrendingUpIcon,
|
conversion: TrendingUpIcon,
|
||||||
|
sankey: GitBranchIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
255
apps/start/src/components/report/report-item.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { ReportChart } from '@/components/report-chart';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
|
||||||
|
|
||||||
|
import { timeWindows } from '@openpanel/constants';
|
||||||
|
|
||||||
|
import { useRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export function ReportItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="card h-full flex flex-col animate-pulse">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
||||||
|
<div className="h-4 w-24 bg-muted/50 rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-muted rounded" />
|
||||||
|
<div className="w-8 h-8 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportItem({
|
||||||
|
report,
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
interval,
|
||||||
|
onDelete,
|
||||||
|
onDuplicate,
|
||||||
|
}: {
|
||||||
|
report: any;
|
||||||
|
organizationId: string;
|
||||||
|
projectId: string;
|
||||||
|
range: any;
|
||||||
|
startDate: any;
|
||||||
|
endDate: any;
|
||||||
|
interval: any;
|
||||||
|
onDelete: (reportId: string) => void;
|
||||||
|
onDuplicate: (reportId: string) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const chartRange = report.range;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card h-full flex flex-col">
|
||||||
|
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-pointer -m-4 p-4"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.metaKey) {
|
||||||
|
window.open(
|
||||||
|
`/${organizationId}/${projectId}/reports/${report.id}`,
|
||||||
|
'_blank',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.navigate({
|
||||||
|
to: '/$organizationId/$projectId/reports/$reportId',
|
||||||
|
params: {
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
reportId: report.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
router.navigate({
|
||||||
|
to: '/$organizationId/$projectId/reports/$reportId',
|
||||||
|
params: {
|
||||||
|
organizationId,
|
||||||
|
projectId,
|
||||||
|
reportId: report.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{report.name}</div>
|
||||||
|
{chartRange !== null && (
|
||||||
|
<div className="mt-2 flex gap-2 ">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
(chartRange !== range && range !== null) ||
|
||||||
|
(startDate && endDate)
|
||||||
|
? 'line-through'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||||
|
</span>
|
||||||
|
{startDate && endDate ? (
|
||||||
|
<span>Custom dates</span>
|
||||||
|
) : (
|
||||||
|
range !== null &&
|
||||||
|
chartRange !== range && (
|
||||||
|
<span>
|
||||||
|
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="currentColor"
|
||||||
|
className="opacity-30 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<circle cx="4" cy="4" r="1.5" />
|
||||||
|
<circle cx="4" cy="8" r="1.5" />
|
||||||
|
<circle cx="4" cy="12" r="1.5" />
|
||||||
|
<circle cx="12" cy="4" r="1.5" />
|
||||||
|
<circle cx="12" cy="8" r="1.5" />
|
||||||
|
<circle cx="12" cy="12" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
||||||
|
<MoreHorizontal size={16} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDuplicate(report.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon size={16} className="mr-2" />
|
||||||
|
Duplicate
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete(report.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash size={16} className="mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-4 overflow-auto flex-1',
|
||||||
|
report.chartType === 'metric' && 'p-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReportChart
|
||||||
|
report={{
|
||||||
|
...report,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? null,
|
||||||
|
endDate: endDate ?? null,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReportItemReadOnly({
|
||||||
|
report,
|
||||||
|
shareId,
|
||||||
|
range,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
interval,
|
||||||
|
}: {
|
||||||
|
report: any;
|
||||||
|
shareId: string;
|
||||||
|
range: any;
|
||||||
|
startDate: any;
|
||||||
|
endDate: any;
|
||||||
|
interval: any;
|
||||||
|
}) {
|
||||||
|
const chartRange = report.range;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card h-full flex flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{report.name}</div>
|
||||||
|
{chartRange !== null && (
|
||||||
|
<div className="mt-2 flex gap-2 ">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
(chartRange !== range && range !== null) ||
|
||||||
|
(startDate && endDate)
|
||||||
|
? 'line-through'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
||||||
|
</span>
|
||||||
|
{startDate && endDate ? (
|
||||||
|
<span>Custom dates</span>
|
||||||
|
) : (
|
||||||
|
range !== null &&
|
||||||
|
chartRange !== range && (
|
||||||
|
<span>
|
||||||
|
{timeWindows[range as keyof typeof timeWindows]?.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-4 overflow-auto flex-1',
|
||||||
|
report.chartType === 'metric' && 'p-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReportChart
|
||||||
|
report={{
|
||||||
|
...report,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? null,
|
||||||
|
endDate: endDate ?? null,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
|
}}
|
||||||
|
shareId={shareId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { createSlice } from '@reduxjs/toolkit';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
|
|
||||||
|
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
@@ -12,18 +11,19 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
|
IReport,
|
||||||
|
IReportOptions,
|
||||||
UnionOmit,
|
UnionOmit,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type InitialState = IChartProps & {
|
type InitialState = IReport & {
|
||||||
|
id?: string;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
@@ -34,7 +34,6 @@ type InitialState = IChartProps & {
|
|||||||
const initialState: InitialState = {
|
const initialState: InitialState = {
|
||||||
ready: false,
|
ready: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
// TODO: remove this
|
|
||||||
projectId: '',
|
projectId: '',
|
||||||
name: '',
|
name: '',
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
@@ -50,9 +49,7 @@ const initialState: InitialState = {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
criteria: 'on_or_after',
|
options: undefined,
|
||||||
funnelGroup: undefined,
|
|
||||||
funnelWindow: undefined,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reportSlice = createSlice({
|
export const reportSlice = createSlice({
|
||||||
@@ -74,7 +71,7 @@ export const reportSlice = createSlice({
|
|||||||
ready: true,
|
ready: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setReport(state, action: PayloadAction<IChartProps>) {
|
setReport(state, action: PayloadAction<IReport>) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...action.payload,
|
...action.payload,
|
||||||
@@ -187,6 +184,16 @@ export const reportSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.chartType = action.payload;
|
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 (
|
if (
|
||||||
!isMinuteIntervalEnabledByRange(state.range) &&
|
!isMinuteIntervalEnabledByRange(state.range) &&
|
||||||
state.interval === 'minute'
|
state.interval === 'minute'
|
||||||
@@ -254,7 +261,14 @@ export const reportSlice = createSlice({
|
|||||||
|
|
||||||
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.criteria = action.payload;
|
if (!state.options || state.options.type !== 'retention') {
|
||||||
|
state.options = {
|
||||||
|
type: 'retention',
|
||||||
|
criteria: action.payload,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.criteria = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
changeUnit(state, action: PayloadAction<string | undefined>) {
|
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||||
@@ -264,12 +278,88 @@ export const reportSlice = createSlice({
|
|||||||
|
|
||||||
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.funnelGroup = action.payload || undefined;
|
if (!state.options || state.options.type !== 'funnel') {
|
||||||
|
state.options = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: action.payload,
|
||||||
|
funnelWindow: undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.funnelGroup = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.funnelWindow = action.payload || undefined;
|
if (!state.options || state.options.type !== 'funnel') {
|
||||||
|
state.options = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: undefined,
|
||||||
|
funnelWindow: action.payload,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.funnelWindow = action.payload;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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(
|
reorderEvents(
|
||||||
state,
|
state,
|
||||||
@@ -311,6 +401,11 @@ export const {
|
|||||||
changeUnit,
|
changeUnit,
|
||||||
changeFunnelGroup,
|
changeFunnelGroup,
|
||||||
changeFunnelWindow,
|
changeFunnelWindow,
|
||||||
|
changeOptions,
|
||||||
|
changeSankeyMode,
|
||||||
|
changeSankeySteps,
|
||||||
|
changeSankeyExclude,
|
||||||
|
changeSankeyInclude,
|
||||||
reorderEvents,
|
reorderEvents,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,13 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { shortId } from '@openpanel/common';
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
IChartFormula,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
import { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
||||||
import { ReportSegment } from '../ReportSegment';
|
|
||||||
import {
|
import {
|
||||||
addSerie,
|
addSerie,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
@@ -39,27 +37,21 @@ import {
|
|||||||
removeEvent,
|
removeEvent,
|
||||||
reorderEvents,
|
reorderEvents,
|
||||||
} from '../reportSlice';
|
} from '../reportSlice';
|
||||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
|
||||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
|
||||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||||
import { ReportEventMore } from './ReportEventMore';
|
import { ReportEventMore } from './ReportEventMore';
|
||||||
import { FiltersList } from './filters/FiltersList';
|
import {
|
||||||
|
ReportSeriesItem,
|
||||||
|
type ReportSeriesItemProps,
|
||||||
|
} from './ReportSeriesItem';
|
||||||
|
|
||||||
function SortableSeries({
|
function SortableReportSeriesItem({
|
||||||
event,
|
event,
|
||||||
index,
|
index,
|
||||||
showSegment,
|
showSegment,
|
||||||
showAddFilter,
|
showAddFilter,
|
||||||
isSelectManyEvents,
|
isSelectManyEvents,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
|
||||||
event: IChartEventItem | IChartEvent;
|
|
||||||
index: number;
|
|
||||||
showSegment: boolean;
|
|
||||||
showAddFilter: boolean;
|
|
||||||
isSelectManyEvents: boolean;
|
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
const dispatch = useDispatch();
|
|
||||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
useSortable({ id: eventId ?? '' });
|
useSortable({ id: eventId ?? '' });
|
||||||
@@ -69,85 +61,26 @@ function SortableSeries({
|
|||||||
transition,
|
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 (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
<div className="flex items-center gap-2 p-2 group">
|
<ReportSeriesItem
|
||||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
event={event}
|
||||||
<ColorSquare className="relative">
|
index={index}
|
||||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
showSegment={showSegment}
|
||||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
showAddFilter={showAddFilter}
|
||||||
{alphabetIds[index]}
|
isSelectManyEvents={isSelectManyEvents}
|
||||||
</span>
|
renderDragHandle={(index) => (
|
||||||
</ColorSquare>
|
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||||
</button>
|
<ColorSquare className="relative">
|
||||||
{props.children}
|
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||||
</div>
|
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||||
|
{alphabetIds[index]}
|
||||||
{/* Segment and Filter buttons - only for events */}
|
</span>
|
||||||
{chartEvent && (showSegment || showAddFilter) && (
|
</ColorSquare>
|
||||||
<div className="flex gap-2 p-2 pt-0">
|
</button>
|
||||||
{showSegment && (
|
)}
|
||||||
<ReportSegment
|
{...props}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,12 +94,23 @@ export function ReportSeries() {
|
|||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
|
||||||
const showAddFilter = !['retention'].includes(chartType);
|
const showAddFilter = !['retention', 'sankey'].includes(chartType);
|
||||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
|
||||||
|
const options = useSelector((state) => state.report.options);
|
||||||
|
const isSankey = chartType === 'sankey';
|
||||||
const isAddEventDisabled =
|
const isAddEventDisabled =
|
||||||
(chartType === 'retention' || chartType === 'conversion') &&
|
(chartType === 'retention' || chartType === 'conversion') &&
|
||||||
selectedSeries.length >= 2;
|
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) => {
|
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||||
dispatch(changeEvent(event));
|
dispatch(changeEvent(event));
|
||||||
});
|
});
|
||||||
@@ -218,7 +162,8 @@ export function ReportSeries() {
|
|||||||
const showFormula =
|
const showFormula =
|
||||||
chartType !== 'conversion' &&
|
chartType !== 'conversion' &&
|
||||||
chartType !== 'funnel' &&
|
chartType !== 'funnel' &&
|
||||||
chartType !== 'retention';
|
chartType !== 'retention' &&
|
||||||
|
chartType !== 'sankey';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -239,7 +184,7 @@ export function ReportSeries() {
|
|||||||
const isFormula = event.type === 'formula';
|
const isFormula = event.type === 'formula';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableSeries
|
<SortableReportSeriesItem
|
||||||
key={event.id}
|
key={event.id}
|
||||||
event={event}
|
event={event}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -348,13 +293,14 @@ export function ReportSeries() {
|
|||||||
<ReportEventMore onClick={handleMore(event)} />
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SortableSeries>
|
</SortableReportSeriesItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ComboboxEvents
|
<ComboboxEvents
|
||||||
disabled={isAddEventDisabled}
|
className="flex-1"
|
||||||
|
disabled={isAddEventDisabled || isSankeyEventLimitReached}
|
||||||
value={''}
|
value={''}
|
||||||
searchable
|
searchable
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -386,13 +332,13 @@ export function ReportSeries() {
|
|||||||
}}
|
}}
|
||||||
placeholder="Select event"
|
placeholder="Select event"
|
||||||
items={eventNames}
|
items={eventNames}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
{showFormula && (
|
{showFormula && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={PiIcon}
|
icon={PiIcon}
|
||||||
|
className="flex-1 justify-start text-left px-4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
addSerie({
|
addSerie({
|
||||||
@@ -402,9 +348,9 @@ export function ReportSeries() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="px-4"
|
|
||||||
>
|
>
|
||||||
Add Formula
|
Add Formula
|
||||||
|
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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,32 +1,46 @@
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
|
|
||||||
|
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||||
import { InputEnter } from '@/components/ui/input-enter';
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Switch } from '@/components/ui/switch';
|
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 { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
changeCriteria,
|
changeCriteria,
|
||||||
changeFunnelGroup,
|
changeFunnelGroup,
|
||||||
changeFunnelWindow,
|
changeFunnelWindow,
|
||||||
changePrevious,
|
changePrevious,
|
||||||
|
changeSankeyExclude,
|
||||||
|
changeSankeyInclude,
|
||||||
|
changeSankeyMode,
|
||||||
|
changeSankeySteps,
|
||||||
changeUnit,
|
changeUnit,
|
||||||
} from '../reportSlice';
|
} from '../reportSlice';
|
||||||
|
|
||||||
export function ReportSettings() {
|
export function ReportSettings() {
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const previous = useSelector((state) => state.report.previous);
|
const previous = useSelector((state) => state.report.previous);
|
||||||
const criteria = useSelector((state) => state.report.criteria);
|
|
||||||
const unit = useSelector((state) => state.report.unit);
|
const unit = useSelector((state) => state.report.unit);
|
||||||
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
const options = useSelector((state) => state.report.options);
|
||||||
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
|
||||||
|
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||||
|
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const eventNames = useEventNames({ projectId });
|
||||||
|
|
||||||
const fields = useMemo(() => {
|
const fields = useMemo(() => {
|
||||||
const fields = [];
|
const fields = [];
|
||||||
|
|
||||||
if (chartType !== 'retention') {
|
if (chartType !== 'retention' && chartType !== 'sankey') {
|
||||||
fields.push('previous');
|
fields.push('previous');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +54,13 @@ export function ReportSettings() {
|
|||||||
fields.push('funnelWindow');
|
fields.push('funnelWindow');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (chartType === 'sankey') {
|
||||||
|
fields.push('sankeyMode');
|
||||||
|
fields.push('sankeySteps');
|
||||||
|
fields.push('sankeyExclude');
|
||||||
|
fields.push('sankeyInclude');
|
||||||
|
}
|
||||||
|
|
||||||
return fields;
|
return fields;
|
||||||
}, [chartType]);
|
}, [chartType]);
|
||||||
|
|
||||||
@@ -50,7 +71,7 @@ export function ReportSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Settings</h3>
|
<h3 className="mb-2 font-medium">Settings</h3>
|
||||||
<div className="col rounded-lg border bg-card p-4 gap-2">
|
<div className="col rounded-lg border bg-card p-4 gap-4">
|
||||||
{fields.includes('previous') && (
|
{fields.includes('previous') && (
|
||||||
<Label className="flex items-center justify-between mb-0">
|
<Label className="flex items-center justify-between mb-0">
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
@@ -64,7 +85,9 @@ export function ReportSettings() {
|
|||||||
)}
|
)}
|
||||||
{fields.includes('criteria') && (
|
{fields.includes('criteria') && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="whitespace-nowrap font-medium">Criteria</span>
|
<Label className="whitespace-nowrap font-medium mb-0">
|
||||||
|
Criteria
|
||||||
|
</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
align="end"
|
align="end"
|
||||||
placeholder="Select criteria"
|
placeholder="Select criteria"
|
||||||
@@ -85,7 +108,7 @@ export function ReportSettings() {
|
|||||||
)}
|
)}
|
||||||
{fields.includes('unit') && (
|
{fields.includes('unit') && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="whitespace-nowrap font-medium">Unit</span>
|
<Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
align="end"
|
align="end"
|
||||||
placeholder="Unit"
|
placeholder="Unit"
|
||||||
@@ -108,7 +131,9 @@ export function ReportSettings() {
|
|||||||
)}
|
)}
|
||||||
{fields.includes('funnelGroup') && (
|
{fields.includes('funnelGroup') && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="whitespace-nowrap font-medium">Funnel Group</span>
|
<Label className="whitespace-nowrap font-medium mb-0">
|
||||||
|
Funnel Group
|
||||||
|
</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
align="end"
|
align="end"
|
||||||
placeholder="Default: Session"
|
placeholder="Default: Session"
|
||||||
@@ -133,7 +158,9 @@ export function ReportSettings() {
|
|||||||
)}
|
)}
|
||||||
{fields.includes('funnelWindow') && (
|
{fields.includes('funnelWindow') && (
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="whitespace-nowrap font-medium">Funnel Window</span>
|
<Label className="whitespace-nowrap font-medium mb-0">
|
||||||
|
Funnel Window
|
||||||
|
</Label>
|
||||||
<InputEnter
|
<InputEnter
|
||||||
type="number"
|
type="number"
|
||||||
value={funnelWindow ? String(funnelWindow) : ''}
|
value={funnelWindow ? String(funnelWindow) : ''}
|
||||||
@@ -149,6 +176,89 @@ export function ReportSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
|
||||||
|
<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">
|
||||||
|
<Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
|
||||||
|
<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">
|
||||||
|
<Label className="whitespace-nowrap font-medium">
|
||||||
|
Exclude Events
|
||||||
|
</Label>
|
||||||
|
<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">
|
||||||
|
<Label className="whitespace-nowrap font-medium">
|
||||||
|
Include events
|
||||||
|
</Label>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
|
|||||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportSeries } from './ReportSeries';
|
import { ReportSeries } from './ReportSeries';
|
||||||
import { ReportSettings } from './ReportSettings';
|
import { ReportSettings } from './ReportSettings';
|
||||||
|
import { ReportFixedEvents } from './report-fixed-events';
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType, options } = useSelector((state) => state.report);
|
||||||
const showBreakdown = chartType !== 'retention';
|
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
|
||||||
|
const showFixedEvents = chartType === 'sankey';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportSeries />
|
{showFixedEvents ? (
|
||||||
|
<ReportFixedEvents
|
||||||
|
numberOfEvents={
|
||||||
|
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ReportSeries />
|
||||||
|
)}
|
||||||
{showBreakdown && <ReportBreakdowns />}
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
<ReportSettings />
|
<ReportSettings />
|
||||||
</div>
|
</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>
|
<CommandEmpty>Nothing selected</CommandEmpty>
|
||||||
<VirtualList
|
<VirtualList
|
||||||
height={400}
|
height={300}
|
||||||
data={items.filter((item) => {
|
data={items.filter((item) => {
|
||||||
if (search === '') return true;
|
if (search === '') return true;
|
||||||
return item.name.toLowerCase().includes(search.toLowerCase());
|
return item.name.toLowerCase().includes(search.toLowerCase());
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70',
|
||||||
);
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
|
|||||||
@@ -25,12 +25,40 @@ export function createTRPCClientWithHeaders(apiUrl: string) {
|
|||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
url: `${apiUrl}/trpc`,
|
url: `${apiUrl}/trpc`,
|
||||||
headers: () => getIsomorphicHeaders(),
|
headers: () => getIsomorphicHeaders(),
|
||||||
fetch: (url, options) => {
|
fetch: async (url, options) => {
|
||||||
return fetch(url, {
|
try {
|
||||||
...options,
|
console.log('fetching', url, options);
|
||||||
mode: 'cors',
|
const response = await fetch(url, {
|
||||||
credentials: 'include',
|
...options,
|
||||||
});
|
mode: 'cors',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log HTTP errors on server
|
||||||
|
if (!response.ok && typeof window === 'undefined') {
|
||||||
|
const text = await response.clone().text();
|
||||||
|
console.error('[tRPC SSR Error]', {
|
||||||
|
url: url.toString(),
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: text,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Log fetch errors on server
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
console.error('[tRPC SSR Error]', {
|
||||||
|
url: url.toString(),
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ import OverviewFilters from './overview-filters';
|
|||||||
import RequestPasswordReset from './request-reset-password';
|
import RequestPasswordReset from './request-reset-password';
|
||||||
import SaveReport from './save-report';
|
import SaveReport from './save-report';
|
||||||
import SelectBillingPlan from './select-billing-plan';
|
import SelectBillingPlan from './select-billing-plan';
|
||||||
|
import ShareDashboardModal from './share-dashboard-modal';
|
||||||
import ShareOverviewModal from './share-overview-modal';
|
import ShareOverviewModal from './share-overview-modal';
|
||||||
|
import ShareReportModal from './share-report-modal';
|
||||||
import ViewChartUsers from './view-chart-users';
|
import ViewChartUsers from './view-chart-users';
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
@@ -51,6 +53,8 @@ const modals = {
|
|||||||
EditReport: EditReport,
|
EditReport: EditReport,
|
||||||
EditReference: EditReference,
|
EditReference: EditReference,
|
||||||
ShareOverviewModal: ShareOverviewModal,
|
ShareOverviewModal: ShareOverviewModal,
|
||||||
|
ShareDashboardModal: ShareDashboardModal,
|
||||||
|
ShareReportModal: ShareReportModal,
|
||||||
AddReference: AddReference,
|
AddReference: AddReference,
|
||||||
ViewChartUsers: ViewChartUsers,
|
ViewChartUsers: ViewChartUsers,
|
||||||
Instructions: Instructions,
|
Instructions: Instructions,
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ReportChart } from '@/components/report-chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
chart: IChartProps;
|
chart: IReport;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OverviewChartDetails = (props: Props) => {
|
const OverviewChartDetails = (props: Props) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -21,7 +21,7 @@ import { popModal } from '.';
|
|||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
type SaveReportProps = {
|
type SaveReportProps = {
|
||||||
report: IChartProps;
|
report: IReport;
|
||||||
disableRedirect?: boolean;
|
disableRedirect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
192
apps/start/src/modals/share-dashboard-modal.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { handleError } from '@/integrations/trpc/react';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { zShareDashboard } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
|
const validator = zShareDashboard;
|
||||||
|
|
||||||
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
|
export default function ShareDashboardModal({
|
||||||
|
dashboardId,
|
||||||
|
}: {
|
||||||
|
dashboardId: string;
|
||||||
|
}) {
|
||||||
|
const { projectId, organizationId } = useAppParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current share status
|
||||||
|
const shareQuery = useQuery(
|
||||||
|
trpc.share.dashboard.queryOptions({
|
||||||
|
dashboardId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingShare = shareQuery.data;
|
||||||
|
const isShared = existingShare?.public ?? false;
|
||||||
|
const shareUrl = existingShare?.id
|
||||||
|
? `${window.location.origin}/share/dashboard/${existingShare.id}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||||
|
resolver: zodResolver(validator),
|
||||||
|
defaultValues: {
|
||||||
|
public: true,
|
||||||
|
password: existingShare?.password ? '••••••••' : '',
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
dashboardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch('password');
|
||||||
|
|
||||||
|
const mutation = useMutation(
|
||||||
|
trpc.share.createDashboard.mutationOptions({
|
||||||
|
onError: handleError,
|
||||||
|
onSuccess(res) {
|
||||||
|
queryClient.invalidateQueries(trpc.share.dashboard.pathFilter());
|
||||||
|
toast('Success', {
|
||||||
|
description: `Your dashboard is now ${
|
||||||
|
res.public ? 'public' : 'private'
|
||||||
|
}`,
|
||||||
|
action: res.public
|
||||||
|
? {
|
||||||
|
label: 'View',
|
||||||
|
onClick: () =>
|
||||||
|
navigate({
|
||||||
|
to: '/share/dashboard/$shareId',
|
||||||
|
params: {
|
||||||
|
shareId: res.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
popModal();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast('Link copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakePrivate = () => {
|
||||||
|
mutation.mutate({
|
||||||
|
public: false,
|
||||||
|
password: null,
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
dashboardId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent className="max-w-md">
|
||||||
|
<ModalHeader
|
||||||
|
title="Dashboard public availability"
|
||||||
|
text={
|
||||||
|
isShared
|
||||||
|
? 'Your dashboard is currently public and can be accessed by anyone with the link.'
|
||||||
|
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isShared && (
|
||||||
|
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
<span className="font-medium">Currently shared</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||||
|
<Tooltiper content="Copy link">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Open in new tab">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(shareUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Make private">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleMakePrivate}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((values) => {
|
||||||
|
mutation.mutate({
|
||||||
|
...values,
|
||||||
|
// Only send password if it's not the placeholder
|
||||||
|
password:
|
||||||
|
values.password === '••••••••' ? null : values.password || null,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register('password')}
|
||||||
|
placeholder="Enter your password (optional)"
|
||||||
|
size="large"
|
||||||
|
type={password === '••••••••' ? 'text' : 'password'}
|
||||||
|
/>
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
{isShared ? 'Update' : 'Make it public'}
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ import type { z } from 'zod';
|
|||||||
import { zShareOverview } from '@openpanel/validation';
|
import { zShareOverview } from '@openpanel/validation';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
@@ -23,19 +26,36 @@ type IForm = z.infer<typeof validator>;
|
|||||||
export default function ShareOverviewModal() {
|
export default function ShareOverviewModal() {
|
||||||
const { projectId, organizationId } = useAppParams();
|
const { projectId, organizationId } = useAppParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const { register, handleSubmit } = useForm<IForm>({
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current share status
|
||||||
|
const shareQuery = useQuery(
|
||||||
|
trpc.share.overview.queryOptions({
|
||||||
|
projectId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingShare = shareQuery.data;
|
||||||
|
const isShared = existingShare?.public ?? false;
|
||||||
|
const shareUrl = existingShare?.id
|
||||||
|
? `${window.location.origin}/share/overview/${existingShare.id}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||||
resolver: zodResolver(validator),
|
resolver: zodResolver(validator),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
public: true,
|
public: true,
|
||||||
password: '',
|
password: existingShare?.password ? '••••••••' : '',
|
||||||
projectId,
|
projectId,
|
||||||
organizationId,
|
organizationId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const trpc = useTRPC();
|
const password = watch('password');
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.share.createOverview.mutationOptions({
|
trpc.share.createOverview.mutationOptions({
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
@@ -45,47 +65,122 @@ export default function ShareOverviewModal() {
|
|||||||
description: `Your overview is now ${
|
description: `Your overview is now ${
|
||||||
res.public ? 'public' : 'private'
|
res.public ? 'public' : 'private'
|
||||||
}`,
|
}`,
|
||||||
action: {
|
action: res.public
|
||||||
label: 'View',
|
? {
|
||||||
onClick: () =>
|
label: 'View',
|
||||||
navigate({
|
onClick: () =>
|
||||||
to: '/share/overview/$shareId',
|
navigate({
|
||||||
params: {
|
to: '/share/overview/$shareId',
|
||||||
shareId: res.id,
|
params: {
|
||||||
},
|
shareId: res.id,
|
||||||
}),
|
},
|
||||||
},
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
popModal();
|
popModal();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast('Link copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakePrivate = () => {
|
||||||
|
mutation.mutate({
|
||||||
|
public: false,
|
||||||
|
password: null,
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent className="max-w-md">
|
<ModalContent className="max-w-md">
|
||||||
<ModalHeader
|
<ModalHeader
|
||||||
title="Dashboard public availability"
|
title="Overview public availability"
|
||||||
text="You can choose if you want to add a password to make it a bit more private."
|
text={
|
||||||
|
isShared
|
||||||
|
? 'Your overview is currently public and can be accessed by anyone with the link.'
|
||||||
|
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isShared && (
|
||||||
|
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
<span className="font-medium">Currently shared</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||||
|
<Tooltiper content="Copy link">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Open in new tab">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(shareUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Make private">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleMakePrivate}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((values) => {
|
onSubmit={handleSubmit((values) => {
|
||||||
mutation.mutate(values);
|
mutation.mutate({
|
||||||
|
...values,
|
||||||
|
// Only send password if it's not the placeholder
|
||||||
|
password:
|
||||||
|
values.password === '••••••••' ? null : values.password || null,
|
||||||
|
});
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
{...register('password')}
|
{...register('password')}
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password (optional)"
|
||||||
size="large"
|
size="large"
|
||||||
|
type={password === '••••••••' ? 'text' : 'password'}
|
||||||
/>
|
/>
|
||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={mutation.isPending}>
|
|
||||||
Make it public
|
<Button type="submit">
|
||||||
|
{isShared ? 'Update' : 'Make it public'}
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
</form>
|
</form>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
186
apps/start/src/modals/share-report-modal.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { handleError } from '@/integrations/trpc/react';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { zShareReport } from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2, Copy, ExternalLink, TrashIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
|
const validator = zShareReport;
|
||||||
|
|
||||||
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
|
export default function ShareReportModal({ reportId }: { reportId: string }) {
|
||||||
|
const { projectId, organizationId } = useAppParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch current share status
|
||||||
|
const shareQuery = useQuery(
|
||||||
|
trpc.share.report.queryOptions({
|
||||||
|
reportId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingShare = shareQuery.data;
|
||||||
|
const isShared = existingShare?.public ?? false;
|
||||||
|
const shareUrl = existingShare?.id
|
||||||
|
? `${window.location.origin}/share/report/${existingShare.id}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const { register, handleSubmit, watch } = useForm<IForm>({
|
||||||
|
resolver: zodResolver(validator),
|
||||||
|
defaultValues: {
|
||||||
|
public: true,
|
||||||
|
password: existingShare?.password ? '••••••••' : '',
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
reportId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const password = watch('password');
|
||||||
|
|
||||||
|
const mutation = useMutation(
|
||||||
|
trpc.share.createReport.mutationOptions({
|
||||||
|
onError: handleError,
|
||||||
|
onSuccess(res) {
|
||||||
|
queryClient.invalidateQueries(trpc.share.report.pathFilter());
|
||||||
|
toast('Success', {
|
||||||
|
description: `Your report is now ${res.public ? 'public' : 'private'}`,
|
||||||
|
action: res.public
|
||||||
|
? {
|
||||||
|
label: 'View',
|
||||||
|
onClick: () =>
|
||||||
|
navigate({
|
||||||
|
to: '/share/report/$shareId',
|
||||||
|
params: {
|
||||||
|
shareId: res.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
popModal();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyLink = () => {
|
||||||
|
navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast('Link copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakePrivate = () => {
|
||||||
|
mutation.mutate({
|
||||||
|
public: false,
|
||||||
|
password: null,
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
reportId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent className="max-w-md">
|
||||||
|
<ModalHeader
|
||||||
|
title="Report public availability"
|
||||||
|
text={
|
||||||
|
isShared
|
||||||
|
? 'Your report is currently public and can be accessed by anyone with the link.'
|
||||||
|
: 'You can choose if you want to add a password to make it a bit more private.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isShared && (
|
||||||
|
<div className="p-4 bg-def-100 border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
<span className="font-medium">Currently shared</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Input value={shareUrl} readOnly className="flex-1 text-sm" />
|
||||||
|
<Tooltiper content="Copy link">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Open in new tab">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(shareUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
<Tooltiper content="Make private">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleMakePrivate}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit((values) => {
|
||||||
|
mutation.mutate({
|
||||||
|
...values,
|
||||||
|
// Only send password if it's not the placeholder
|
||||||
|
password:
|
||||||
|
values.password === '••••••••' ? null : values.password || null,
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
{...register('password')}
|
||||||
|
placeholder="Enter your password (optional)"
|
||||||
|
size="large"
|
||||||
|
type={password === '••••••••' ? 'text' : 'password'}
|
||||||
|
/>
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
{isShared ? 'Update' : 'Make it public'}
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { useTRPC } from '@/integrations/trpc/react';
|
|||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -152,7 +152,7 @@ function ProfileList({ profiles }: { profiles: any[] }) {
|
|||||||
// Chart-specific props and component
|
// Chart-specific props and component
|
||||||
interface ChartUsersViewProps {
|
interface ChartUsersViewProps {
|
||||||
chartData: IChartData;
|
chartData: IChartData;
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
|||||||
|
|
||||||
// Funnel-specific props and component
|
// Funnel-specific props and component
|
||||||
interface FunnelUsersViewProps {
|
interface FunnelUsersViewProps {
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +297,14 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
|||||||
series: report.series,
|
series: report.series,
|
||||||
stepIndex: stepIndex,
|
stepIndex: stepIndex,
|
||||||
showDropoffs: showDropoffs,
|
showDropoffs: showDropoffs,
|
||||||
funnelWindow: report.funnelWindow,
|
funnelWindow:
|
||||||
funnelGroup: report.funnelGroup,
|
report.options?.type === 'funnel'
|
||||||
|
? report.options.funnelWindow
|
||||||
|
: undefined,
|
||||||
|
funnelGroup:
|
||||||
|
report.options?.type === 'funnel'
|
||||||
|
? report.options.funnelGroup
|
||||||
|
: undefined,
|
||||||
breakdowns: report.breakdowns,
|
breakdowns: report.breakdowns,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -371,12 +377,12 @@ type ViewChartUsersProps =
|
|||||||
| {
|
| {
|
||||||
type: 'chart';
|
type: 'chart';
|
||||||
chartData: IChartData;
|
chartData: IChartData;
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'funnel';
|
type: 'funnel';
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import { Route as PublicRouteImport } from './routes/_public'
|
|||||||
import { Route as LoginRouteImport } from './routes/_login'
|
import { Route as LoginRouteImport } from './routes/_login'
|
||||||
import { Route as AppRouteImport } from './routes/_app'
|
import { Route as AppRouteImport } from './routes/_app'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as WidgetTestRouteImport } from './routes/widget/test'
|
||||||
|
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
||||||
|
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
|
||||||
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
||||||
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
||||||
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
||||||
@@ -23,7 +26,9 @@ import { Route as LoginResetPasswordRouteImport } from './routes/_login.reset-pa
|
|||||||
import { Route as LoginLoginRouteImport } from './routes/_login.login'
|
import { Route as LoginLoginRouteImport } from './routes/_login.login'
|
||||||
import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId'
|
import { Route as AppOrganizationIdRouteImport } from './routes/_app.$organizationId'
|
||||||
import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index'
|
import { Route as AppOrganizationIdIndexRouteImport } from './routes/_app.$organizationId.index'
|
||||||
|
import { Route as ShareReportShareIdRouteImport } from './routes/share.report.$shareId'
|
||||||
import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId'
|
import { Route as ShareOverviewShareIdRouteImport } from './routes/share.overview.$shareId'
|
||||||
|
import { Route as ShareDashboardShareIdRouteImport } from './routes/share.dashboard.$shareId'
|
||||||
import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project'
|
import { Route as StepsOnboardingProjectRouteImport } from './routes/_steps.onboarding.project'
|
||||||
import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings'
|
import { Route as AppOrganizationIdSettingsRouteImport } from './routes/_app.$organizationId.settings'
|
||||||
import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing'
|
import { Route as AppOrganizationIdBillingRouteImport } from './routes/_app.$organizationId.billing'
|
||||||
@@ -58,6 +63,7 @@ import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from
|
|||||||
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
|
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
|
||||||
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
|
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
|
||||||
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
|
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
|
||||||
|
import { Route as AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.widgets'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
|
||||||
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
|
||||||
@@ -117,6 +123,21 @@ const IndexRoute = IndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const WidgetTestRoute = WidgetTestRouteImport.update({
|
||||||
|
id: '/widget/test',
|
||||||
|
path: '/widget/test',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WidgetRealtimeRoute = WidgetRealtimeRouteImport.update({
|
||||||
|
id: '/widget/realtime',
|
||||||
|
path: '/widget/realtime',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const WidgetCounterRoute = WidgetCounterRouteImport.update({
|
||||||
|
id: '/widget/counter',
|
||||||
|
path: '/widget/counter',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
||||||
id: '/api/healthcheck',
|
id: '/api/healthcheck',
|
||||||
path: '/api/healthcheck',
|
path: '/api/healthcheck',
|
||||||
@@ -164,11 +185,21 @@ const AppOrganizationIdIndexRoute = AppOrganizationIdIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdRoute,
|
getParentRoute: () => AppOrganizationIdRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ShareReportShareIdRoute = ShareReportShareIdRouteImport.update({
|
||||||
|
id: '/share/report/$shareId',
|
||||||
|
path: '/share/report/$shareId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({
|
const ShareOverviewShareIdRoute = ShareOverviewShareIdRouteImport.update({
|
||||||
id: '/share/overview/$shareId',
|
id: '/share/overview/$shareId',
|
||||||
path: '/share/overview/$shareId',
|
path: '/share/overview/$shareId',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ShareDashboardShareIdRoute = ShareDashboardShareIdRouteImport.update({
|
||||||
|
id: '/share/dashboard/$shareId',
|
||||||
|
path: '/share/dashboard/$shareId',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({
|
const StepsOnboardingProjectRoute = StepsOnboardingProjectRouteImport.update({
|
||||||
id: '/onboarding/project',
|
id: '/onboarding/project',
|
||||||
path: '/onboarding/project',
|
path: '/onboarding/project',
|
||||||
@@ -396,6 +427,12 @@ const AppOrganizationIdProjectIdEventsTabsIndexRoute =
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AppOrganizationIdProjectIdEventsTabsRoute,
|
getParentRoute: () => AppOrganizationIdProjectIdEventsTabsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AppOrganizationIdProjectIdSettingsTabsWidgetsRoute =
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport.update({
|
||||||
|
id: '/widgets',
|
||||||
|
path: '/widgets',
|
||||||
|
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
|
||||||
|
} as any)
|
||||||
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
|
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
|
||||||
id: '/imports',
|
id: '/imports',
|
||||||
@@ -494,11 +531,16 @@ export interface FileRoutesByFullPath {
|
|||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
|
'/widget/test': typeof WidgetTestRoute
|
||||||
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
'/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||||
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||||
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||||
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||||
|
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||||
|
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||||
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
'/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
@@ -539,6 +581,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
'/$organizationId/$projectId/events/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
||||||
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
'/$organizationId/$projectId/notifications/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||||
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/$organizationId/$projectId/profiles/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
@@ -553,10 +596,15 @@ export interface FileRoutesByTo {
|
|||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
|
'/widget/test': typeof WidgetTestRoute
|
||||||
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
'/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||||
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
'/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||||
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
'/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||||
|
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||||
|
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||||
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
'/$organizationId': typeof AppOrganizationIdIndexRoute
|
||||||
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
@@ -595,6 +643,7 @@ export interface FileRoutesByTo {
|
|||||||
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/$organizationId/$projectId/settings/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/$organizationId/$projectId/settings/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/$organizationId/$projectId/settings/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
|
'/$organizationId/$projectId/settings/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
'/$organizationId/$projectId/profiles/$profileId/events': typeof AppOrganizationIdProjectIdProfilesProfileIdTabsEventsRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
@@ -610,11 +659,16 @@ export interface FileRoutesById {
|
|||||||
'/_public/onboarding': typeof PublicOnboardingRoute
|
'/_public/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
|
'/widget/test': typeof WidgetTestRoute
|
||||||
'/_app/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
'/_app/$organizationId/$projectId': typeof AppOrganizationIdProjectIdRouteWithChildren
|
||||||
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
'/_app/$organizationId/billing': typeof AppOrganizationIdBillingRoute
|
||||||
'/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
'/_app/$organizationId/settings': typeof AppOrganizationIdSettingsRoute
|
||||||
'/_steps/onboarding/project': typeof StepsOnboardingProjectRoute
|
'/_steps/onboarding/project': typeof StepsOnboardingProjectRoute
|
||||||
|
'/share/dashboard/$shareId': typeof ShareDashboardShareIdRoute
|
||||||
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
'/share/overview/$shareId': typeof ShareOverviewShareIdRoute
|
||||||
|
'/share/report/$shareId': typeof ShareReportShareIdRoute
|
||||||
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
'/_app/$organizationId/': typeof AppOrganizationIdIndexRoute
|
||||||
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
'/_app/$organizationId/$projectId/chat': typeof AppOrganizationIdProjectIdChatRoute
|
||||||
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
'/_app/$organizationId/$projectId/dashboards': typeof AppOrganizationIdProjectIdDashboardsRoute
|
||||||
@@ -662,6 +716,7 @@ export interface FileRoutesById {
|
|||||||
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets': typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
|
||||||
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
|
||||||
@@ -679,11 +734,16 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/counter'
|
||||||
|
| '/widget/realtime'
|
||||||
|
| '/widget/test'
|
||||||
| '/$organizationId/$projectId'
|
| '/$organizationId/$projectId'
|
||||||
| '/$organizationId/billing'
|
| '/$organizationId/billing'
|
||||||
| '/$organizationId/settings'
|
| '/$organizationId/settings'
|
||||||
| '/onboarding/project'
|
| '/onboarding/project'
|
||||||
|
| '/share/dashboard/$shareId'
|
||||||
| '/share/overview/$shareId'
|
| '/share/overview/$shareId'
|
||||||
|
| '/share/report/$shareId'
|
||||||
| '/$organizationId/'
|
| '/$organizationId/'
|
||||||
| '/$organizationId/$projectId/chat'
|
| '/$organizationId/$projectId/chat'
|
||||||
| '/$organizationId/$projectId/dashboards'
|
| '/$organizationId/$projectId/dashboards'
|
||||||
@@ -724,6 +784,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
| '/$organizationId/$projectId/events/'
|
| '/$organizationId/$projectId/events/'
|
||||||
| '/$organizationId/$projectId/notifications/'
|
| '/$organizationId/$projectId/notifications/'
|
||||||
| '/$organizationId/$projectId/profiles/'
|
| '/$organizationId/$projectId/profiles/'
|
||||||
@@ -738,10 +799,15 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/counter'
|
||||||
|
| '/widget/realtime'
|
||||||
|
| '/widget/test'
|
||||||
| '/$organizationId/billing'
|
| '/$organizationId/billing'
|
||||||
| '/$organizationId/settings'
|
| '/$organizationId/settings'
|
||||||
| '/onboarding/project'
|
| '/onboarding/project'
|
||||||
|
| '/share/dashboard/$shareId'
|
||||||
| '/share/overview/$shareId'
|
| '/share/overview/$shareId'
|
||||||
|
| '/share/report/$shareId'
|
||||||
| '/$organizationId'
|
| '/$organizationId'
|
||||||
| '/$organizationId/$projectId/chat'
|
| '/$organizationId/$projectId/chat'
|
||||||
| '/$organizationId/$projectId/dashboards'
|
| '/$organizationId/$projectId/dashboards'
|
||||||
@@ -780,6 +846,7 @@ export interface FileRouteTypes {
|
|||||||
| '/$organizationId/$projectId/settings/details'
|
| '/$organizationId/$projectId/settings/details'
|
||||||
| '/$organizationId/$projectId/settings/events'
|
| '/$organizationId/$projectId/settings/events'
|
||||||
| '/$organizationId/$projectId/settings/imports'
|
| '/$organizationId/$projectId/settings/imports'
|
||||||
|
| '/$organizationId/$projectId/settings/widgets'
|
||||||
| '/$organizationId/$projectId/profiles/$profileId/events'
|
| '/$organizationId/$projectId/profiles/$profileId/events'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
@@ -794,11 +861,16 @@ export interface FileRouteTypes {
|
|||||||
| '/_public/onboarding'
|
| '/_public/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/counter'
|
||||||
|
| '/widget/realtime'
|
||||||
|
| '/widget/test'
|
||||||
| '/_app/$organizationId/$projectId'
|
| '/_app/$organizationId/$projectId'
|
||||||
| '/_app/$organizationId/billing'
|
| '/_app/$organizationId/billing'
|
||||||
| '/_app/$organizationId/settings'
|
| '/_app/$organizationId/settings'
|
||||||
| '/_steps/onboarding/project'
|
| '/_steps/onboarding/project'
|
||||||
|
| '/share/dashboard/$shareId'
|
||||||
| '/share/overview/$shareId'
|
| '/share/overview/$shareId'
|
||||||
|
| '/share/report/$shareId'
|
||||||
| '/_app/$organizationId/'
|
| '/_app/$organizationId/'
|
||||||
| '/_app/$organizationId/$projectId/chat'
|
| '/_app/$organizationId/$projectId/chat'
|
||||||
| '/_app/$organizationId/$projectId/dashboards'
|
| '/_app/$organizationId/$projectId/dashboards'
|
||||||
@@ -846,6 +918,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
| '/_app/$organizationId/$projectId/settings/_tabs/details'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
| '/_app/$organizationId/$projectId/settings/_tabs/events'
|
||||||
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||||
|
| '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||||
| '/_app/$organizationId/$projectId/events/_tabs/'
|
| '/_app/$organizationId/$projectId/events/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
| '/_app/$organizationId/$projectId/notifications/_tabs/'
|
||||||
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
| '/_app/$organizationId/$projectId/profiles/_tabs/'
|
||||||
@@ -862,7 +935,12 @@ export interface RootRouteChildren {
|
|||||||
StepsRoute: typeof StepsRouteWithChildren
|
StepsRoute: typeof StepsRouteWithChildren
|
||||||
ApiConfigRoute: typeof ApiConfigRoute
|
ApiConfigRoute: typeof ApiConfigRoute
|
||||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||||
|
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||||
|
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
||||||
|
WidgetTestRoute: typeof WidgetTestRoute
|
||||||
|
ShareDashboardShareIdRoute: typeof ShareDashboardShareIdRoute
|
||||||
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
|
ShareOverviewShareIdRoute: typeof ShareOverviewShareIdRoute
|
||||||
|
ShareReportShareIdRoute: typeof ShareReportShareIdRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -902,6 +980,27 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof IndexRouteImport
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/widget/test': {
|
||||||
|
id: '/widget/test'
|
||||||
|
path: '/widget/test'
|
||||||
|
fullPath: '/widget/test'
|
||||||
|
preLoaderRoute: typeof WidgetTestRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/widget/realtime': {
|
||||||
|
id: '/widget/realtime'
|
||||||
|
path: '/widget/realtime'
|
||||||
|
fullPath: '/widget/realtime'
|
||||||
|
preLoaderRoute: typeof WidgetRealtimeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/widget/counter': {
|
||||||
|
id: '/widget/counter'
|
||||||
|
path: '/widget/counter'
|
||||||
|
fullPath: '/widget/counter'
|
||||||
|
preLoaderRoute: typeof WidgetCounterRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/healthcheck': {
|
'/api/healthcheck': {
|
||||||
id: '/api/healthcheck'
|
id: '/api/healthcheck'
|
||||||
path: '/api/healthcheck'
|
path: '/api/healthcheck'
|
||||||
@@ -965,6 +1064,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdRoute
|
parentRoute: typeof AppOrganizationIdRoute
|
||||||
}
|
}
|
||||||
|
'/share/report/$shareId': {
|
||||||
|
id: '/share/report/$shareId'
|
||||||
|
path: '/share/report/$shareId'
|
||||||
|
fullPath: '/share/report/$shareId'
|
||||||
|
preLoaderRoute: typeof ShareReportShareIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/share/overview/$shareId': {
|
'/share/overview/$shareId': {
|
||||||
id: '/share/overview/$shareId'
|
id: '/share/overview/$shareId'
|
||||||
path: '/share/overview/$shareId'
|
path: '/share/overview/$shareId'
|
||||||
@@ -972,6 +1078,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ShareOverviewShareIdRouteImport
|
preLoaderRoute: typeof ShareOverviewShareIdRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/share/dashboard/$shareId': {
|
||||||
|
id: '/share/dashboard/$shareId'
|
||||||
|
path: '/share/dashboard/$shareId'
|
||||||
|
fullPath: '/share/dashboard/$shareId'
|
||||||
|
preLoaderRoute: typeof ShareDashboardShareIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_steps/onboarding/project': {
|
'/_steps/onboarding/project': {
|
||||||
id: '/_steps/onboarding/project'
|
id: '/_steps/onboarding/project'
|
||||||
path: '/onboarding/project'
|
path: '/onboarding/project'
|
||||||
@@ -1245,6 +1358,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
|
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
|
||||||
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
|
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
|
||||||
}
|
}
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets': {
|
||||||
|
id: '/_app/$organizationId/$projectId/settings/_tabs/widgets'
|
||||||
|
path: '/widgets'
|
||||||
|
fullPath: '/$organizationId/$projectId/settings/widgets'
|
||||||
|
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRouteImport
|
||||||
|
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
|
||||||
|
}
|
||||||
'/_app/$organizationId/$projectId/settings/_tabs/imports': {
|
'/_app/$organizationId/$projectId/settings/_tabs/imports': {
|
||||||
id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
|
||||||
path: '/imports'
|
path: '/imports'
|
||||||
@@ -1508,6 +1628,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
|
|||||||
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute: typeof AppOrganizationIdProjectIdSettingsTabsWidgetsRoute
|
||||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1521,6 +1642,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
|
|||||||
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute:
|
||||||
|
AppOrganizationIdProjectIdSettingsTabsWidgetsRoute,
|
||||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
|
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
|
||||||
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
|
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
|
||||||
}
|
}
|
||||||
@@ -1751,7 +1874,12 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
StepsRoute: StepsRouteWithChildren,
|
StepsRoute: StepsRouteWithChildren,
|
||||||
ApiConfigRoute: ApiConfigRoute,
|
ApiConfigRoute: ApiConfigRoute,
|
||||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||||
|
WidgetCounterRoute: WidgetCounterRoute,
|
||||||
|
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
||||||
|
WidgetTestRoute: WidgetTestRoute,
|
||||||
|
ShareDashboardShareIdRoute: ShareDashboardShareIdRoute,
|
||||||
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
|
ShareOverviewShareIdRoute: ShareOverviewShareIdRoute,
|
||||||
|
ShareReportShareIdRoute: ShareReportShareIdRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
HeadContent,
|
HeadContent,
|
||||||
Scripts,
|
Scripts,
|
||||||
createRootRouteWithContext,
|
createRootRouteWithContext,
|
||||||
useRouteContext,
|
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
|
|
||||||
import 'flag-icons/css/flag-icons.min.css';
|
import 'flag-icons/css/flag-icons.min.css';
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
BarChartHorizontalIcon,
|
BarChartHorizontalIcon,
|
||||||
ChartScatterIcon,
|
ChartScatterIcon,
|
||||||
ConeIcon,
|
ConeIcon,
|
||||||
|
GitBranchIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
@@ -153,6 +154,7 @@ function Component() {
|
|||||||
area: AreaChartIcon,
|
area: AreaChartIcon,
|
||||||
retention: ChartScatterIcon,
|
retention: ChartScatterIcon,
|
||||||
conversion: TrendingUpIcon,
|
conversion: TrendingUpIcon,
|
||||||
|
sankey: GitBranchIcon,
|
||||||
}[report.chartType];
|
}[report.chartType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { ReportChart } from '@/components/report-chart';
|
|
||||||
import { Button, LinkButton } from '@/components/ui/button';
|
import { Button, LinkButton } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -9,48 +8,36 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { createProjectTitle } from '@/utils/title';
|
import { createProjectTitle } from '@/utils/title';
|
||||||
import {
|
import {
|
||||||
CopyIcon,
|
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash,
|
ShareIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { timeWindows } from '@openpanel/constants';
|
|
||||||
|
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import {
|
||||||
|
GrafanaGrid,
|
||||||
|
type Layout,
|
||||||
|
useReportLayouts,
|
||||||
|
} from '@/components/grafana-grid';
|
||||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
|
import {
|
||||||
|
ReportItem,
|
||||||
|
ReportItemSkeleton,
|
||||||
|
} from '@/components/report/report-item';
|
||||||
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
|
import { handleErrorToastOptions, useTRPC } from '@/integrations/trpc/react';
|
||||||
import { showConfirm } from '@/modals';
|
import { pushModal, showConfirm } from '@/modals';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
import { createFileRoute, useRouter } from '@tanstack/react-router';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Responsive, WidthProvider } from 'react-grid-layout';
|
|
||||||
import 'react-grid-layout/css/styles.css';
|
|
||||||
import 'react-resizable/css/styles.css';
|
|
||||||
|
|
||||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
|
||||||
|
|
||||||
type Layout = {
|
|
||||||
i: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
w: number;
|
|
||||||
h: number;
|
|
||||||
minW?: number;
|
|
||||||
minH?: number;
|
|
||||||
maxW?: number;
|
|
||||||
maxH?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
|
'/_app/$organizationId/$projectId/dashboards_/$dashboardId',
|
||||||
@@ -94,180 +81,6 @@ export const Route = createFileRoute(
|
|||||||
pendingComponent: FullPageLoadingState,
|
pendingComponent: FullPageLoadingState,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Report Skeleton Component
|
|
||||||
function ReportSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="card h-full flex flex-col animate-pulse">
|
|
||||||
<div className="flex items-center justify-between border-b border-border p-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-5 w-32 bg-muted rounded mb-2" />
|
|
||||||
<div className="h-4 w-24 bg-muted/50 rounded" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-8 h-8 bg-muted rounded" />
|
|
||||||
<div className="w-8 h-8 bg-muted rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report Item Component
|
|
||||||
function ReportItem({
|
|
||||||
report,
|
|
||||||
organizationId,
|
|
||||||
projectId,
|
|
||||||
range,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
interval,
|
|
||||||
onDelete,
|
|
||||||
onDuplicate,
|
|
||||||
}: {
|
|
||||||
report: any;
|
|
||||||
organizationId: string;
|
|
||||||
projectId: string;
|
|
||||||
range: any;
|
|
||||||
startDate: any;
|
|
||||||
endDate: any;
|
|
||||||
interval: any;
|
|
||||||
onDelete: (reportId: string) => void;
|
|
||||||
onDuplicate: (reportId: string) => void;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const chartRange = report.range;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card h-full flex flex-col">
|
|
||||||
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
|
|
||||||
<div
|
|
||||||
className="flex-1 cursor-pointer -m-4 p-4"
|
|
||||||
onClick={(event) => {
|
|
||||||
if (event.metaKey) {
|
|
||||||
window.open(
|
|
||||||
`/${organizationId}/${projectId}/reports/${report.id}`,
|
|
||||||
'_blank',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.navigate({
|
|
||||||
from: Route.fullPath,
|
|
||||||
to: '/$organizationId/$projectId/reports/$reportId',
|
|
||||||
params: {
|
|
||||||
reportId: report.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onKeyUp={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
router.navigate({
|
|
||||||
from: Route.fullPath,
|
|
||||||
to: '/$organizationId/$projectId/reports/$reportId',
|
|
||||||
params: {
|
|
||||||
reportId: report.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{report.name}</div>
|
|
||||||
{chartRange !== null && (
|
|
||||||
<div className="mt-2 flex gap-2 ">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
(chartRange !== range && range !== null) ||
|
|
||||||
(startDate && endDate)
|
|
||||||
? 'line-through'
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
|
|
||||||
</span>
|
|
||||||
{startDate && endDate ? (
|
|
||||||
<span>Custom dates</span>
|
|
||||||
) : (
|
|
||||||
range !== null &&
|
|
||||||
chartRange !== range && (
|
|
||||||
<span>
|
|
||||||
{timeWindows[range as keyof typeof timeWindows]?.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
fill="currentColor"
|
|
||||||
className="opacity-30 hover:opacity-100"
|
|
||||||
>
|
|
||||||
<circle cx="4" cy="4" r="1.5" />
|
|
||||||
<circle cx="4" cy="8" r="1.5" />
|
|
||||||
<circle cx="4" cy="12" r="1.5" />
|
|
||||||
<circle cx="12" cy="4" r="1.5" />
|
|
||||||
<circle cx="12" cy="8" r="1.5" />
|
|
||||||
<circle cx="12" cy="12" r="1.5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
|
|
||||||
<MoreHorizontal size={16} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-[200px]">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDuplicate(report.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CopyIcon size={16} className="mr-2" />
|
|
||||||
Duplicate
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDelete(report.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash size={16} className="mr-2" />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'p-4 overflow-auto flex-1',
|
|
||||||
report.chartType === 'metric' && 'p-0',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ReportChart
|
|
||||||
report={
|
|
||||||
{
|
|
||||||
...report,
|
|
||||||
range: range ?? report.range,
|
|
||||||
startDate: startDate ?? null,
|
|
||||||
endDate: endDate ?? null,
|
|
||||||
interval: interval ?? report.interval,
|
|
||||||
} as any
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Component() {
|
function Component() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { organizationId, dashboardId, projectId } = Route.useParams();
|
const { organizationId, dashboardId, projectId } = Route.useParams();
|
||||||
@@ -363,26 +176,7 @@ function Component() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Convert reports to grid layout format for all breakpoints
|
// Convert reports to grid layout format for all breakpoints
|
||||||
const layouts = useMemo(() => {
|
const layouts = useReportLayouts(reports);
|
||||||
const baseLayout = reports.map((report, index) => ({
|
|
||||||
i: report.id,
|
|
||||||
x: report.layout?.x ?? (index % 2) * 6,
|
|
||||||
y: report.layout?.y ?? Math.floor(index / 2) * 4,
|
|
||||||
w: report.layout?.w ?? 6,
|
|
||||||
h: report.layout?.h ?? 4,
|
|
||||||
minW: 3,
|
|
||||||
minH: 3,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Create responsive layouts for different breakpoints
|
|
||||||
return {
|
|
||||||
lg: baseLayout,
|
|
||||||
md: baseLayout,
|
|
||||||
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
|
|
||||||
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
|
|
||||||
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
|
|
||||||
};
|
|
||||||
}, [reports]);
|
|
||||||
|
|
||||||
const handleLayoutChange = useCallback((newLayout: Layout[]) => {
|
const handleLayoutChange = useCallback((newLayout: Layout[]) => {
|
||||||
// This is called during dragging/resizing, we'll save on drag/resize stop
|
// This is called during dragging/resizing, we'll save on drag/resize stop
|
||||||
@@ -463,7 +257,7 @@ function Component() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title={dashboard.name}
|
title={dashboard.name}
|
||||||
description="View and manage your reports"
|
description="View and manage your reports"
|
||||||
className="mb-0"
|
className="mb-4"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<OverviewRange />
|
<OverviewRange />
|
||||||
@@ -484,6 +278,14 @@ function Component() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-[200px]">
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
pushModal('ShareDashboardModal', { dashboardId })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShareIcon className="mr-2 size-4" />
|
||||||
|
Share dashboard
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
showConfirm({
|
showConfirm({
|
||||||
@@ -532,69 +334,43 @@ function Component() {
|
|||||||
</FullPageEmptyState>
|
</FullPageEmptyState>
|
||||||
) : !isGridReady || reportsQuery.isLoading ? (
|
) : !isGridReady || reportsQuery.isLoading ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
<ReportSkeleton />
|
<ReportItemSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full overflow-hidden -mx-4">
|
<GrafanaGrid
|
||||||
<style>{`
|
transitions={enableTransitions}
|
||||||
.react-grid-item {
|
layouts={layouts}
|
||||||
transition: ${enableTransitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
|
onLayoutChange={handleLayoutChange}
|
||||||
}
|
onDragStop={handleDragStop}
|
||||||
.react-grid-item.react-grid-placeholder {
|
onResizeStop={handleResizeStop}
|
||||||
background: none !important;
|
isDraggable={true}
|
||||||
opacity: 0.5;
|
isResizable={true}
|
||||||
transition-duration: 100ms;
|
>
|
||||||
border-radius: 0.5rem;
|
{reports.map((report) => (
|
||||||
border: 1px dashed var(--primary);
|
<div key={report.id}>
|
||||||
}
|
<ReportItem
|
||||||
.react-grid-item.resizing {
|
report={report}
|
||||||
transition: none !important;
|
organizationId={organizationId}
|
||||||
}
|
projectId={projectId}
|
||||||
`}</style>
|
range={range}
|
||||||
<ResponsiveGridLayout
|
startDate={startDate}
|
||||||
className="layout"
|
endDate={endDate}
|
||||||
layouts={layouts}
|
interval={interval}
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
onDelete={(reportId) => {
|
||||||
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
|
reportDeletion.mutate({ reportId });
|
||||||
rowHeight={100}
|
}}
|
||||||
onLayoutChange={handleLayoutChange}
|
onDuplicate={(reportId) => {
|
||||||
onDragStop={handleDragStop}
|
reportDuplicate.mutate({ reportId });
|
||||||
onResizeStop={handleResizeStop}
|
}}
|
||||||
draggableHandle=".drag-handle"
|
/>
|
||||||
compactType="vertical"
|
</div>
|
||||||
preventCollision={false}
|
))}
|
||||||
isDraggable={true}
|
</GrafanaGrid>
|
||||||
isResizable={true}
|
|
||||||
margin={[16, 16]}
|
|
||||||
transformScale={1}
|
|
||||||
useCSSTransforms={true}
|
|
||||||
>
|
|
||||||
{reports.map((report) => (
|
|
||||||
<div key={report.id}>
|
|
||||||
<ReportItem
|
|
||||||
report={report}
|
|
||||||
organizationId={organizationId}
|
|
||||||
projectId={projectId}
|
|
||||||
range={range}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
interval={interval}
|
|
||||||
onDelete={(reportId) => {
|
|
||||||
reportDeletion.mutate({ reportId });
|
|
||||||
}}
|
|
||||||
onDuplicate={(reportId) => {
|
|
||||||
reportDuplicate.mutate({ reportId });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ResponsiveGridLayout>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -274,20 +274,16 @@ const PageCard = memo(
|
|||||||
</div>
|
</div>
|
||||||
<ReportChart
|
<ReportChart
|
||||||
options={{
|
options={{
|
||||||
hideID: true,
|
|
||||||
hideXAxis: true,
|
hideXAxis: true,
|
||||||
hideYAxis: true,
|
hideYAxis: true,
|
||||||
aspectRatio: 0.15,
|
aspectRatio: 0.15,
|
||||||
}}
|
}}
|
||||||
report={{
|
report={{
|
||||||
lineType: 'linear',
|
|
||||||
breakdowns: [],
|
breakdowns: [],
|
||||||
name: 'screen_view',
|
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
range,
|
range,
|
||||||
interval,
|
interval,
|
||||||
previous: true,
|
previous: true,
|
||||||
|
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
projectId,
|
projectId,
|
||||||
series: [
|
series: [
|
||||||
|
|||||||
@@ -36,5 +36,6 @@ function Component() {
|
|||||||
const { reportId } = Route.useParams();
|
const { reportId } = Route.useParams();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
|
const query = useSuspenseQuery(trpc.report.get.queryOptions({ reportId }));
|
||||||
|
console.log(query.data);
|
||||||
return <ReportEditor report={query.data} />;
|
return <ReportEditor report={query.data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function ProjectDashboard() {
|
|||||||
{ id: 'details', label: 'Details' },
|
{ id: 'details', label: 'Details' },
|
||||||
{ id: 'events', label: 'Events' },
|
{ id: 'events', label: 'Events' },
|
||||||
{ id: 'clients', label: 'Clients' },
|
{ id: 'clients', label: 'Clients' },
|
||||||
|
{ id: 'widgets', label: 'Widgets' },
|
||||||
{ id: 'imports', label: 'Imports' },
|
{ id: 'imports', label: 'Imports' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import CopyInput from '@/components/forms/copy-input';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import Syntax from '@/components/syntax';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import type {
|
||||||
|
IRealtimeWidgetOptions,
|
||||||
|
IWidgetType,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export const Route = createFileRoute(
|
||||||
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets',
|
||||||
|
)({
|
||||||
|
component: Component,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Component() {
|
||||||
|
const { projectId, organizationId } = useAppParams();
|
||||||
|
const { dashboardUrl } = useAppContext();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Fetch both widget types
|
||||||
|
const realtimeWidgetQuery = useQuery(
|
||||||
|
trpc.widget.get.queryOptions({ projectId, type: 'realtime' }),
|
||||||
|
);
|
||||||
|
const counterWidgetQuery = useQuery(
|
||||||
|
trpc.widget.get.queryOptions({ projectId, type: 'counter' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle mutation
|
||||||
|
const toggleMutation = useMutation(
|
||||||
|
trpc.widget.toggle.mutationOptions({
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
trpc.widget.get.queryFilter({ projectId, type: variables.type }),
|
||||||
|
);
|
||||||
|
toast.success(variables.enabled ? 'Widget enabled' : 'Widget disabled');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to update widget');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update options mutation
|
||||||
|
const updateOptionsMutation = useMutation(
|
||||||
|
trpc.widget.updateOptions.mutationOptions({
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
trpc.widget.get.queryFilter({ projectId, type: 'realtime' }),
|
||||||
|
);
|
||||||
|
toast.success('Widget options updated');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || 'Failed to update options');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = (type: IWidgetType, enabled: boolean) => {
|
||||||
|
toggleMutation.mutate({
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
type,
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (realtimeWidgetQuery.isLoading || counterWidgetQuery.isLoading) {
|
||||||
|
return <FullPageLoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realtimeWidget = realtimeWidgetQuery.data;
|
||||||
|
const counterWidget = counterWidgetQuery.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{realtimeWidget && (
|
||||||
|
<RealtimeWidgetSection
|
||||||
|
widget={realtimeWidget as any}
|
||||||
|
dashboardUrl={dashboardUrl}
|
||||||
|
isToggling={toggleMutation.isPending}
|
||||||
|
isUpdatingOptions={updateOptionsMutation.isPending}
|
||||||
|
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
||||||
|
onUpdateOptions={(options) =>
|
||||||
|
updateOptionsMutation.mutate({
|
||||||
|
projectId,
|
||||||
|
organizationId,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{counterWidget && (
|
||||||
|
<CounterWidgetSection
|
||||||
|
widget={counterWidget}
|
||||||
|
dashboardUrl={dashboardUrl}
|
||||||
|
isToggling={toggleMutation.isPending}
|
||||||
|
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealtimeWidgetSectionProps {
|
||||||
|
widget: {
|
||||||
|
id: string;
|
||||||
|
public: boolean;
|
||||||
|
options: IRealtimeWidgetOptions;
|
||||||
|
} | null;
|
||||||
|
dashboardUrl: string;
|
||||||
|
isToggling: boolean;
|
||||||
|
isUpdatingOptions: boolean;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
onUpdateOptions: (options: IRealtimeWidgetOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RealtimeWidgetSection({
|
||||||
|
widget,
|
||||||
|
dashboardUrl,
|
||||||
|
isToggling,
|
||||||
|
isUpdatingOptions,
|
||||||
|
onToggle,
|
||||||
|
onUpdateOptions,
|
||||||
|
}: RealtimeWidgetSectionProps) {
|
||||||
|
const isEnabled = widget?.public ?? false;
|
||||||
|
const widgetUrl =
|
||||||
|
isEnabled && widget?.id
|
||||||
|
? `${dashboardUrl}/widget/realtime?shareId=${widget.id}`
|
||||||
|
: null;
|
||||||
|
const embedCode = widgetUrl
|
||||||
|
? `<iframe src="${widgetUrl}" width="100%" height="400" frameborder="0" style="border-radius: 8px;"></iframe>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Default options
|
||||||
|
const defaultOptions: IRealtimeWidgetOptions = {
|
||||||
|
type: 'realtime',
|
||||||
|
referrers: true,
|
||||||
|
countries: true,
|
||||||
|
paths: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
||||||
|
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update local options when widget data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (widget?.options) {
|
||||||
|
setOptions(widget.options as IRealtimeWidgetOptions);
|
||||||
|
}
|
||||||
|
}, [widget?.options]);
|
||||||
|
|
||||||
|
const handleUpdateOptions = (newOptions: IRealtimeWidgetOptions) => {
|
||||||
|
setOptions(newOptions);
|
||||||
|
onUpdateOptions(newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="max-w-screen-md w-full">
|
||||||
|
<WidgetHead className="row items-center justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="title">Realtime Widget</span>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Embed a realtime visitor counter widget on your website. The widget
|
||||||
|
shows live visitor count, activity histogram, top countries,
|
||||||
|
referrers and paths.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
/>
|
||||||
|
</WidgetHead>
|
||||||
|
{isEnabled && (
|
||||||
|
<WidgetBody className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-sm font-medium">Widget Options</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="referrers" className="text-sm">
|
||||||
|
Show Referrers
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="referrers"
|
||||||
|
checked={options.referrers}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleUpdateOptions({ ...options, referrers: checked })
|
||||||
|
}
|
||||||
|
disabled={isUpdatingOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="countries" className="text-sm">
|
||||||
|
Show Countries
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="countries"
|
||||||
|
checked={options.countries}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleUpdateOptions({ ...options, countries: checked })
|
||||||
|
}
|
||||||
|
disabled={isUpdatingOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="paths" className="text-sm">
|
||||||
|
Show Paths
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="paths"
|
||||||
|
checked={options.paths}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleUpdateOptions({ ...options, paths: checked })
|
||||||
|
}
|
||||||
|
disabled={isUpdatingOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||||
|
<CopyInput label="" value={widgetUrl!} className="w-full" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Direct link to the widget. You can open this in a new tab or embed
|
||||||
|
it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||||
|
<Syntax code={embedCode!} language="bash" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Copy this code and paste it into your website HTML where you want
|
||||||
|
the widget to appear.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Preview</h3>
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<iframe
|
||||||
|
src={widgetUrl!}
|
||||||
|
width="100%"
|
||||||
|
height="600"
|
||||||
|
className="border-0"
|
||||||
|
title="Realtime Widget Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={ExternalLinkIcon}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(widgetUrl!, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidgetBody>
|
||||||
|
)}
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CounterWidgetSectionProps {
|
||||||
|
widget: {
|
||||||
|
id: string;
|
||||||
|
public: boolean;
|
||||||
|
} | null;
|
||||||
|
dashboardUrl: string;
|
||||||
|
isToggling: boolean;
|
||||||
|
onToggle: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CounterWidgetSection({
|
||||||
|
widget,
|
||||||
|
dashboardUrl,
|
||||||
|
isToggling,
|
||||||
|
onToggle,
|
||||||
|
}: CounterWidgetSectionProps) {
|
||||||
|
const isEnabled = widget?.public ?? false;
|
||||||
|
const counterUrl =
|
||||||
|
isEnabled && widget?.id
|
||||||
|
? `${dashboardUrl}/widget/counter?shareId=${widget.id}`
|
||||||
|
: null;
|
||||||
|
const counterEmbedCode = counterUrl
|
||||||
|
? `<iframe src="${counterUrl}" height="32" style="border: none; overflow: hidden;" title="Visitor Counter"></iframe>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="max-w-screen-md w-full">
|
||||||
|
<WidgetHead className="row items-center justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="title">Counter Widget</span>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
A compact live visitor counter badge you can embed anywhere. Shows
|
||||||
|
the current number of unique visitors with a live indicator.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
/>
|
||||||
|
</WidgetHead>
|
||||||
|
{isEnabled && counterUrl && (
|
||||||
|
<WidgetBody className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||||
|
<CopyInput label="" value={counterUrl} className="w-full" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Direct link to the counter widget.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||||
|
<Syntax code={counterEmbedCode!} language="bash" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Copy this code and paste it into your website HTML where you want
|
||||||
|
the counter to appear.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Preview</h3>
|
||||||
|
<div className="border rounded-lg p-4 bg-muted/30">
|
||||||
|
<iframe
|
||||||
|
src={counterUrl}
|
||||||
|
height="32"
|
||||||
|
className="border-0"
|
||||||
|
title="Counter Widget Preview"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={ExternalLinkIcon}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(counterUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidgetBody>
|
||||||
|
)}
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
apps/start/src/routes/share.dashboard.$shareId.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||||
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { GrafanaGrid, useReportLayouts } from '@/components/grafana-grid';
|
||||||
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
|
import { ReportChart } from '@/components/report-chart';
|
||||||
|
import {
|
||||||
|
ReportItem,
|
||||||
|
ReportItemReadOnly,
|
||||||
|
ReportItemSkeleton,
|
||||||
|
} from '@/components/report/report-item';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { timeWindows } from '@openpanel/constants';
|
||||||
|
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const shareSearchSchema = z.object({
|
||||||
|
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/share/dashboard/$shareId')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: shareSearchSchema,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
const share = await context.queryClient.ensureQueryData(
|
||||||
|
context.trpc.share.dashboard.queryOptions({
|
||||||
|
shareId: params.shareId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { share };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => {
|
||||||
|
if (!loaderData || !loaderData.share) {
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: 'Share not found - OpenPanel.dev',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: `${loaderData.share.dashboard?.name} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pendingComponent: FullPageLoadingState,
|
||||||
|
errorComponent: () => (
|
||||||
|
<FullPageEmptyState
|
||||||
|
title="Share not found"
|
||||||
|
description="The dashboard you are looking for does not exist."
|
||||||
|
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { shareId } = Route.useParams();
|
||||||
|
const { header } = useSearch({ from: '/share/dashboard/$shareId' });
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
|
const shareQuery = useSuspenseQuery(
|
||||||
|
trpc.share.dashboard.queryOptions({
|
||||||
|
shareId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const reportsQuery = useQuery(
|
||||||
|
trpc.share.dashboardReports.queryOptions({
|
||||||
|
shareId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAccess = shareQuery.data?.hasAccess;
|
||||||
|
|
||||||
|
if (!shareQuery.data) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareQuery.data.public) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = shareQuery.data;
|
||||||
|
|
||||||
|
// Handle password protection
|
||||||
|
if (share.password && !hasAccess) {
|
||||||
|
return <ShareEnterPassword shareId={share.id} shareType="dashboard" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHeaderVisible =
|
||||||
|
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||||
|
|
||||||
|
const reports = reportsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Convert reports to grid layout format for all breakpoints
|
||||||
|
const layouts = useReportLayouts(reports);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isHeaderVisible && (
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<LoginNavbar className="relative p-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sticky-header [animation-range:50px_100px]!">
|
||||||
|
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||||
|
<div className="row justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-7xl p-4">
|
||||||
|
{reportsQuery.isLoading ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
<ReportItemSkeleton />
|
||||||
|
</div>
|
||||||
|
) : reports.length === 0 ? (
|
||||||
|
<FullPageEmptyState title="No reports" />
|
||||||
|
) : (
|
||||||
|
<GrafanaGrid layouts={layouts}>
|
||||||
|
{reports.map((report) => (
|
||||||
|
<div key={report.id}>
|
||||||
|
<ReportItemReadOnly
|
||||||
|
report={report}
|
||||||
|
shareId={shareId}
|
||||||
|
range={range}
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
interval={interval}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GrafanaGrid>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,8 @@ import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
|||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||||
import { EyeClosedIcon, FrownIcon } from 'lucide-react';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const shareSearchSchema = z.object({
|
const shareSearchSchema = z.object({
|
||||||
|
|||||||
123
apps/start/src/routes/share.report.$shareId.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { ShareEnterPassword } from '@/components/auth/share-enter-password';
|
||||||
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
|
import { ReportChart } from '@/components/report-chart';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute, notFound, useSearch } from '@tanstack/react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const shareSearchSchema = z.object({
|
||||||
|
header: z.optional(z.number().or(z.string().or(z.boolean()))),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/share/report/$shareId')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: shareSearchSchema,
|
||||||
|
loader: async ({ context, params }) => {
|
||||||
|
const share = await context.queryClient.ensureQueryData(
|
||||||
|
context.trpc.share.report.queryOptions({
|
||||||
|
shareId: params.shareId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
return { share: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { share };
|
||||||
|
},
|
||||||
|
head: ({ loaderData }) => {
|
||||||
|
if (!loaderData || !loaderData.share) {
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: 'Share not found - OpenPanel.dev',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
title: `${loaderData.share.report.name || 'Report'} - ${loaderData.share.organization?.name} - OpenPanel.dev`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
pendingComponent: FullPageLoadingState,
|
||||||
|
errorComponent: () => (
|
||||||
|
<FullPageEmptyState
|
||||||
|
title="Share not found"
|
||||||
|
description="The report you are looking for does not exist."
|
||||||
|
className="min-h-[calc(100vh-theme(spacing.16))]"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { shareId } = Route.useParams();
|
||||||
|
const { header } = useSearch({ from: '/share/report/$shareId' });
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const shareQuery = useSuspenseQuery(
|
||||||
|
trpc.share.report.queryOptions({
|
||||||
|
shareId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAccess = shareQuery.data?.hasAccess;
|
||||||
|
|
||||||
|
if (!shareQuery.data) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareQuery.data.public) {
|
||||||
|
throw notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = shareQuery.data;
|
||||||
|
|
||||||
|
console.log('share', share);
|
||||||
|
|
||||||
|
// Handle password protection
|
||||||
|
if (share.password && !hasAccess) {
|
||||||
|
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHeaderVisible =
|
||||||
|
header !== '0' && header !== 0 && header !== 'false' && header !== false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isHeaderVisible && (
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<LoginNavbar className="relative p-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sticky-header [animation-range:50px_100px]!">
|
||||||
|
<div className="p-4 col gap-2 mx-auto max-w-7xl">
|
||||||
|
<div className="row justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<OverviewRange />
|
||||||
|
<OverviewInterval />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto max-w-7xl p-4">
|
||||||
|
<div className="card">
|
||||||
|
<div className="p-4 border-b">
|
||||||
|
<div className="font-medium text-xl">{share.report.name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<ReportChart report={share.report} shareId={shareId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/start/src/routes/widget/counter.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { AnimatedNumber } from '@/components/animated-number';
|
||||||
|
import { Ping } from '@/components/ping';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import useWS from '@/hooks/use-ws';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const widgetSearchSchema = z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
limit: z.number().default(10),
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/widget/counter')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: widgetSearchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { shareId, limit, color } = Route.useSearch();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
// Fetch widget data
|
||||||
|
const { data, isLoading } = useQuery(
|
||||||
|
trpc.widget.counter.queryOptions({ shareId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 h-8">
|
||||||
|
<Ping />
|
||||||
|
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 h-8">
|
||||||
|
<Ping className="bg-orange-500" />
|
||||||
|
<AnimatedNumber value={0} suffix=" unique visitors" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <CounterWidget shareId={shareId} data={data} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealtimeWidgetProps {
|
||||||
|
shareId: string;
|
||||||
|
data: RouterOutputs['widget']['counter'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CounterWidget({ shareId, data }: RealtimeWidgetProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const number = useNumber();
|
||||||
|
|
||||||
|
// WebSocket subscription for real-time updates
|
||||||
|
useWS<number>(
|
||||||
|
`/live/visitors/${data.projectId}`,
|
||||||
|
(res) => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
queryClient.refetchQueries(
|
||||||
|
trpc.widget.counter.queryFilter({ shareId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debounce: {
|
||||||
|
delay: 1000,
|
||||||
|
maxWait: 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 h-8">
|
||||||
|
<Ping />
|
||||||
|
<AnimatedNumber value={data.counter} suffix=" unique visitors" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
528
apps/start/src/routes/widget/realtime.tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import { AnimatedNumber } from '@/components/animated-number';
|
||||||
|
import {
|
||||||
|
ChartTooltipContainer,
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { LogoSquare } from '@/components/logo';
|
||||||
|
import { Ping } from '@/components/ping';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import useWS from '@/hooks/use-ws';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { countries } from '@/translations/countries';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import type React from 'react';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const widgetSearchSchema = z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
limit: z.number().default(10),
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/widget/realtime')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: widgetSearchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { shareId, limit, color } = Route.useSearch();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
// Fetch widget data
|
||||||
|
const { data: widgetData, isLoading } = useQuery(
|
||||||
|
trpc.widget.realtimeData.queryOptions({ shareId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <RealtimeWidgetSkeleton limit={limit} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!widgetData) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full center-center bg-background text-foreground col p-4">
|
||||||
|
<LogoSquare className="size-10 mb-4" />
|
||||||
|
<h1 className="text-xl font-semibold">Widget not found</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
This widget is not available or has been removed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RealtimeWidget
|
||||||
|
shareId={shareId}
|
||||||
|
limit={limit}
|
||||||
|
data={widgetData}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RealtimeWidgetProps {
|
||||||
|
shareId: string;
|
||||||
|
limit: number;
|
||||||
|
color: string | undefined;
|
||||||
|
data: RouterOutputs['widget']['realtimeData'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function RealtimeWidget({ shareId, data, limit, color }: RealtimeWidgetProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const number = useNumber();
|
||||||
|
|
||||||
|
// WebSocket subscription for real-time updates
|
||||||
|
useWS<number>(
|
||||||
|
`/live/visitors/${data.projectId}`,
|
||||||
|
() => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
queryClient.refetchQueries(
|
||||||
|
trpc.widget.realtimeData.queryFilter({ shareId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
debounce: {
|
||||||
|
delay: 1000,
|
||||||
|
maxWait: 60000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const maxDomain =
|
||||||
|
Math.max(...data.histogram.map((item) => item.sessionCount), 1) * 1.2;
|
||||||
|
|
||||||
|
const grids = (() => {
|
||||||
|
const countries = data.countries.length > 0 ? 1 : 0;
|
||||||
|
const referrers = data.referrers.length > 0 ? 1 : 0;
|
||||||
|
const paths = data.paths.length > 0 ? 1 : 0;
|
||||||
|
const value = countries + referrers + paths;
|
||||||
|
if (value === 3) return 'md:grid-cols-3';
|
||||||
|
if (value === 2) return 'md:grid-cols-2';
|
||||||
|
return 'md:grid-cols-1';
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full flex-col bg-background text-foreground">
|
||||||
|
{/* Header with live counter */}
|
||||||
|
<div className="border-b p-6 pb-3">
|
||||||
|
<div className="flex items-center justify-between w-full h-4">
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<Ping />
|
||||||
|
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||||
|
USERS IN LAST 30 MINUTES
|
||||||
|
</div>
|
||||||
|
{data.project.domain && <SerieIcon name={data.project.domain} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="font-mono text-6xl font-bold h-18 text-foreground">
|
||||||
|
<AnimatedNumber value={data.liveCount} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-20 w-full flex-col -mt-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={data.histogram}
|
||||||
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
content={CustomTooltip}
|
||||||
|
cursor={{ fill: 'var(--def-100)', radius: 4 }}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }}
|
||||||
|
ticks={[
|
||||||
|
data.histogram[0].time,
|
||||||
|
data.histogram[data.histogram.length - 1].time,
|
||||||
|
]}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis hide domain={[0, maxDomain]} />
|
||||||
|
<Bar
|
||||||
|
dataKey="sessionCount"
|
||||||
|
isAnimationActive={false}
|
||||||
|
radius={[4, 4, 4, 4]}
|
||||||
|
fill={color || 'var(--chart-0)'}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||||
|
{/* Histogram */}
|
||||||
|
{/* Countries and Referrers */}
|
||||||
|
{(data.countries.length > 0 || data.referrers.length > 0) && (
|
||||||
|
<div className={cn('grid grid-cols-1 gap-6', grids)}>
|
||||||
|
{/* Countries */}
|
||||||
|
{data.countries.length > 0 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
COUNTRY
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{(() => {
|
||||||
|
const { visible, rest, restCount } = getRestItems(
|
||||||
|
data.countries,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<RowItem key={item.country} count={item.count}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SerieIcon name={item.country} />
|
||||||
|
<span className="text-sm">
|
||||||
|
{countries[
|
||||||
|
item.country as keyof typeof countries
|
||||||
|
] || item.country}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</RowItem>
|
||||||
|
))}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<RestRow
|
||||||
|
firstName={
|
||||||
|
countries[
|
||||||
|
rest[0].country as keyof typeof countries
|
||||||
|
] || rest[0].country
|
||||||
|
}
|
||||||
|
restCount={rest.length}
|
||||||
|
totalCount={restCount}
|
||||||
|
type="countries"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Referrers */}
|
||||||
|
{data.referrers.length > 0 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
REFERRER
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{(() => {
|
||||||
|
const { visible, rest, restCount } = getRestItems(
|
||||||
|
data.referrers,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<RowItem key={item.referrer} count={item.count}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SerieIcon name={item.referrer} />
|
||||||
|
<span className="truncate text-sm">
|
||||||
|
{item.referrer}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</RowItem>
|
||||||
|
))}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<RestRow
|
||||||
|
firstName={rest[0].referrer}
|
||||||
|
restCount={rest.length}
|
||||||
|
totalCount={restCount}
|
||||||
|
type="referrers"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Paths */}
|
||||||
|
{data.paths.length > 0 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
PATH
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{(() => {
|
||||||
|
const { visible, rest, restCount } = getRestItems(
|
||||||
|
data.paths,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<RowItem key={item.path} count={item.count}>
|
||||||
|
<span className="truncate text-sm">
|
||||||
|
{item.path}
|
||||||
|
</span>
|
||||||
|
</RowItem>
|
||||||
|
))}
|
||||||
|
{rest.length > 0 && (
|
||||||
|
<RestRow
|
||||||
|
firstName={rest[0].path}
|
||||||
|
restCount={rest.length}
|
||||||
|
totalCount={restCount}
|
||||||
|
type="paths"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom tooltip component that uses portals to escape overflow hidden
|
||||||
|
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||||
|
const number = useNumber();
|
||||||
|
|
||||||
|
if (!active || !payload || !payload.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = payload[0].payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartTooltipContainer className="max-w-[100px]">
|
||||||
|
<ChartTooltipHeader>
|
||||||
|
<div>{data.time}</div>
|
||||||
|
</ChartTooltipHeader>
|
||||||
|
<ChartTooltipItem color={getChartColor(0)} innerClassName="row gap-1">
|
||||||
|
<div className="flex-1">Visitors</div>
|
||||||
|
<div>{number.short(data.sessionCount)}</div>
|
||||||
|
</ChartTooltipItem>
|
||||||
|
</ChartTooltipContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function RowItem({
|
||||||
|
children,
|
||||||
|
count,
|
||||||
|
}: { children: React.ReactNode; count: number }) {
|
||||||
|
const number = useNumber();
|
||||||
|
return (
|
||||||
|
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||||
|
{children}
|
||||||
|
<span className="font-semibold">{number.short(count)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRestItems<T extends { count: number }>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
): { visible: T[]; rest: T[]; restCount: number } {
|
||||||
|
const visible = items.slice(0, limit);
|
||||||
|
const rest = items.slice(limit);
|
||||||
|
const restCount = rest.reduce((sum, item) => sum + item.count, 0);
|
||||||
|
return { visible, rest, restCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestRow({
|
||||||
|
firstName,
|
||||||
|
restCount,
|
||||||
|
totalCount,
|
||||||
|
type,
|
||||||
|
}: {
|
||||||
|
firstName: string;
|
||||||
|
restCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
type: 'countries' | 'referrers' | 'paths';
|
||||||
|
}) {
|
||||||
|
const number = useNumber();
|
||||||
|
const otherCount = restCount - 1;
|
||||||
|
const typeLabel =
|
||||||
|
type === 'countries'
|
||||||
|
? otherCount === 1
|
||||||
|
? 'country'
|
||||||
|
: 'countries'
|
||||||
|
: type === 'referrers'
|
||||||
|
? otherCount === 1
|
||||||
|
? 'referrer'
|
||||||
|
: 'referrers'
|
||||||
|
: otherCount === 1
|
||||||
|
? 'path'
|
||||||
|
: 'paths';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b hover:bg-foreground/5 -mx-3">
|
||||||
|
<span className="truncate">
|
||||||
|
{firstName} and {otherCount} more {typeLabel}...
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{number.short(totalCount)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-generated skeleton keys to avoid index-based keys in render
|
||||||
|
const SKELETON_KEYS = {
|
||||||
|
countries: [
|
||||||
|
'country-0',
|
||||||
|
'country-1',
|
||||||
|
'country-2',
|
||||||
|
'country-3',
|
||||||
|
'country-4',
|
||||||
|
'country-5',
|
||||||
|
'country-6',
|
||||||
|
'country-7',
|
||||||
|
'country-8',
|
||||||
|
'country-9',
|
||||||
|
],
|
||||||
|
referrers: [
|
||||||
|
'referrer-0',
|
||||||
|
'referrer-1',
|
||||||
|
'referrer-2',
|
||||||
|
'referrer-3',
|
||||||
|
'referrer-4',
|
||||||
|
'referrer-5',
|
||||||
|
'referrer-6',
|
||||||
|
'referrer-7',
|
||||||
|
'referrer-8',
|
||||||
|
'referrer-9',
|
||||||
|
],
|
||||||
|
paths: [
|
||||||
|
'path-0',
|
||||||
|
'path-1',
|
||||||
|
'path-2',
|
||||||
|
'path-3',
|
||||||
|
'path-4',
|
||||||
|
'path-5',
|
||||||
|
'path-6',
|
||||||
|
'path-7',
|
||||||
|
'path-8',
|
||||||
|
'path-9',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-generated skeleton histogram data
|
||||||
|
const SKELETON_HISTOGRAM = [
|
||||||
|
24, 48, 21, 32, 19, 16, 52, 14, 11, 7, 12, 18, 25, 65, 55, 62, 9, 68, 10, 31,
|
||||||
|
58, 70, 10, 47, 43, 10, 38, 35, 41, 28,
|
||||||
|
];
|
||||||
|
|
||||||
|
function RealtimeWidgetSkeleton({ limit }: { limit: number }) {
|
||||||
|
const itemCount = Math.min(limit, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-full flex-col bg-background text-foreground animate-pulse">
|
||||||
|
{/* Header with live counter */}
|
||||||
|
<div className="border-b p-6 pb-3">
|
||||||
|
<div className="flex items-center justify-between w-full h-4">
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<div className="size-2 rounded-full bg-muted" />
|
||||||
|
<div className="text-sm font-medium text-muted-foreground flex-1">
|
||||||
|
USERS IN LAST 30 MINUTES
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="size-4 shrink-0 rounded bg-muted" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="font-mono text-6xl font-bold h-18 flex items-center py-4 gap-1 row">
|
||||||
|
<div className="h-full w-6 bg-muted rounded" />
|
||||||
|
<div className="h-full w-6 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex h-20 w-full flex-col -mt-4 pb-2.5">
|
||||||
|
<div className="flex-1 row gap-1 h-full">
|
||||||
|
{SKELETON_HISTOGRAM.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index.toString()}
|
||||||
|
style={{ height: `${item}%` }}
|
||||||
|
className="h-full w-full bg-muted rounded mt-auto"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="row justify-between pt-2">
|
||||||
|
<div className="h-3 w-8 bg-muted rounded" />
|
||||||
|
<div className="h-3 w-8 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-6 overflow-auto p-6 hide-scrollbar">
|
||||||
|
{/* Countries, Referrers, and Paths skeleton */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{/* Countries skeleton */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
COUNTRY
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{SKELETON_KEYS.countries.slice(0, itemCount).map((key) => (
|
||||||
|
<RowItemSkeleton key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Referrers skeleton */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
REFERRER
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{SKELETON_KEYS.referrers.slice(0, itemCount).map((key) => (
|
||||||
|
<RowItemSkeleton key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paths skeleton */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="mb-3 text-xs font-medium text-muted-foreground">
|
||||||
|
PATH
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
{SKELETON_KEYS.paths.slice(0, itemCount).map((key) => (
|
||||||
|
<RowItemSkeleton key={key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowItemSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-10 text-sm flex items-center justify-between px-3 py-2 border-b -mx-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="size-5 rounded bg-muted" />
|
||||||
|
<div className="h-4 w-24 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-8 bg-muted rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
apps/start/src/routes/widget/test.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/widget/test')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return (
|
||||||
|
<div className="center-center h-screen w-screen gap-4">
|
||||||
|
<iframe
|
||||||
|
title="Realtime Widget"
|
||||||
|
src="http://localhost:3000/widget/realtime?shareId=qkC561&limit=2"
|
||||||
|
width="300"
|
||||||
|
height="400"
|
||||||
|
className="rounded-xl border"
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
title="Realtime Widget"
|
||||||
|
src="http://localhost:3000/widget/realtime?shareId=qkC562&limit=2"
|
||||||
|
width="300"
|
||||||
|
height="400"
|
||||||
|
className="rounded-xl border"
|
||||||
|
/>
|
||||||
|
<iframe
|
||||||
|
title="Counter Widget"
|
||||||
|
src="http://localhost:3000/widget/counter?shareId=qkC561"
|
||||||
|
height="32"
|
||||||
|
width="auto"
|
||||||
|
frameBorder="0"
|
||||||
|
className="rounded-xl border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -61,7 +61,8 @@
|
|||||||
"nuqs": "patches/nuqs.patch"
|
"nuqs": "patches/nuqs.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rolldown": "1.0.0-beta.43"
|
"rolldown": "1.0.0-beta.43",
|
||||||
|
"esm-env": "npm:esm-env-runtime@^0.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ export const chartTypes = {
|
|||||||
funnel: 'Funnel',
|
funnel: 'Funnel',
|
||||||
retention: 'Retention',
|
retention: 'Retention',
|
||||||
conversion: 'Conversion',
|
conversion: 'Conversion',
|
||||||
|
sankey: 'Sankey',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const chartSegments = {
|
export const chartSegments = {
|
||||||
|
|||||||
90
packages/db/code-migrations/9-migrate-options.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import type { IReportOptions } from '@openpanel/validation';
|
||||||
|
import { db } from '../index';
|
||||||
|
import { printBoxMessage } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
printBoxMessage('🔄 Migrating Legacy Fields to Options', []);
|
||||||
|
|
||||||
|
// Get all reports
|
||||||
|
const reports = await db.report.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
chartType: true,
|
||||||
|
funnelGroup: true,
|
||||||
|
funnelWindow: true,
|
||||||
|
criteria: true,
|
||||||
|
options: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
const currentOptions = report.options as IReportOptions | null | undefined;
|
||||||
|
|
||||||
|
// Skip if options already exists and is valid
|
||||||
|
if (
|
||||||
|
currentOptions &&
|
||||||
|
typeof currentOptions === 'object' &&
|
||||||
|
'type' in currentOptions
|
||||||
|
) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newOptions: IReportOptions | null = null;
|
||||||
|
|
||||||
|
// Migrate based on chart type
|
||||||
|
if (report.chartType === 'funnel') {
|
||||||
|
// Only create options if we have legacy fields to migrate
|
||||||
|
if (report.funnelGroup || report.funnelWindow !== null) {
|
||||||
|
newOptions = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: report.funnelGroup ?? undefined,
|
||||||
|
funnelWindow: report.funnelWindow ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (report.chartType === 'retention') {
|
||||||
|
// Only create options if we have criteria to migrate
|
||||||
|
if (report.criteria) {
|
||||||
|
newOptions = {
|
||||||
|
type: 'retention',
|
||||||
|
criteria: report.criteria as 'on_or_after' | 'on' | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (report.chartType === 'sankey') {
|
||||||
|
// Sankey should already have options, but if not, skip
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if we have new options to set
|
||||||
|
if (newOptions) {
|
||||||
|
console.log(
|
||||||
|
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.report.update({
|
||||||
|
where: { id: report.id },
|
||||||
|
data: {
|
||||||
|
options: newOptions,
|
||||||
|
// Set legacy fields to null after migration
|
||||||
|
funnelGroup: null,
|
||||||
|
funnelWindow: null,
|
||||||
|
criteria: report.chartType === 'retention' ? null : report.criteria,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
migratedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printBoxMessage('✅ Migration Complete', [
|
||||||
|
`Migrated: ${migratedCount} reports`,
|
||||||
|
`Skipped: ${skippedCount} reports (already migrated or no legacy fields)`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export * from './src/services/share.service';
|
|||||||
export * from './src/services/session.service';
|
export * from './src/services/session.service';
|
||||||
export * from './src/services/funnel.service';
|
export * from './src/services/funnel.service';
|
||||||
export * from './src/services/conversion.service';
|
export * from './src/services/conversion.service';
|
||||||
|
export * from './src/services/sankey.service';
|
||||||
export * from './src/services/user.service';
|
export * from './src/services/user.service';
|
||||||
export * from './src/services/reference.service';
|
export * from './src/services/reference.service';
|
||||||
export * from './src/services/id.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;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."share_dashboards" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"dashboardId" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."share_reports" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"reportId" UUID NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"public" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"password" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_dashboards_id_key" ON "public"."share_dashboards"("id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_dashboards_dashboardId_key" ON "public"."share_dashboards"("dashboardId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_reports_id_key" ON "public"."share_reports"("id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_reports_reportId_key" ON "public"."share_reports"("reportId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_dashboardId_fkey" FOREIGN KEY ("dashboardId") REFERENCES "public"."dashboards"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_dashboards" ADD CONSTRAINT "share_dashboards_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_reportId_fkey" FOREIGN KEY ("reportId") REFERENCES "public"."reports"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_reports" ADD CONSTRAINT "share_reports_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."share_widgets" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"organizationId" TEXT NOT NULL,
|
||||||
|
"public" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"options" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "share_widgets_id_key" ON "public"."share_widgets"("id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_widgets" ADD CONSTRAINT "share_widgets_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."share_widgets" ADD CONSTRAINT "share_widgets_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -46,16 +46,19 @@ model Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
projects Project[]
|
projects Project[]
|
||||||
members Member[]
|
members Member[]
|
||||||
createdByUserId String?
|
createdByUserId String?
|
||||||
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
createdBy User? @relation(name: "organizationCreatedBy", fields: [createdByUserId], references: [id], onDelete: SetNull)
|
||||||
ProjectAccess ProjectAccess[]
|
ProjectAccess ProjectAccess[]
|
||||||
Client Client[]
|
Client Client[]
|
||||||
Dashboard Dashboard[]
|
Dashboard Dashboard[]
|
||||||
ShareOverview ShareOverview[]
|
ShareOverview ShareOverview[]
|
||||||
|
ShareDashboard ShareDashboard[]
|
||||||
|
ShareReport ShareReport[]
|
||||||
|
ShareWidget ShareWidget[]
|
||||||
integrations Integration[]
|
integrations Integration[]
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
timezone String?
|
timezone String?
|
||||||
@@ -185,13 +188,16 @@ model Project {
|
|||||||
/// [IPrismaProjectFilters]
|
/// [IPrismaProjectFilters]
|
||||||
filters Json @default("[]")
|
filters Json @default("[]")
|
||||||
|
|
||||||
clients Client[]
|
clients Client[]
|
||||||
reports Report[]
|
reports Report[]
|
||||||
dashboards Dashboard[]
|
dashboards Dashboard[]
|
||||||
share ShareOverview?
|
share ShareOverview?
|
||||||
meta EventMeta[]
|
shareDashboards ShareDashboard[]
|
||||||
references Reference[]
|
shareReports ShareReport[]
|
||||||
access ProjectAccess[]
|
shareWidgets ShareWidget[]
|
||||||
|
meta EventMeta[]
|
||||||
|
references Reference[]
|
||||||
|
access ProjectAccess[]
|
||||||
|
|
||||||
notificationRules NotificationRule[]
|
notificationRules NotificationRule[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
@@ -279,16 +285,18 @@ enum ChartType {
|
|||||||
funnel
|
funnel
|
||||||
retention
|
retention
|
||||||
conversion
|
conversion
|
||||||
|
sankey
|
||||||
}
|
}
|
||||||
|
|
||||||
model Dashboard {
|
model Dashboard {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()"))
|
id String @id @default(dbgenerated("gen_random_uuid()"))
|
||||||
name String
|
name String
|
||||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
organizationId String
|
organizationId String
|
||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
share ShareDashboard?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -321,10 +329,13 @@ model Report {
|
|||||||
criteria String?
|
criteria String?
|
||||||
funnelGroup String?
|
funnelGroup String?
|
||||||
funnelWindow Float?
|
funnelWindow Float?
|
||||||
|
/// [IReportOptions]
|
||||||
|
options Json?
|
||||||
|
|
||||||
dashboardId String
|
dashboardId String
|
||||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||||
layout ReportLayout?
|
layout ReportLayout?
|
||||||
|
share ShareReport?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
@@ -369,6 +380,53 @@ model ShareOverview {
|
|||||||
@@map("shares")
|
@@map("shares")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ShareDashboard {
|
||||||
|
id String @unique
|
||||||
|
dashboardId String @unique
|
||||||
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id], onDelete: Cascade)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
public Boolean @default(false)
|
||||||
|
password String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("share_dashboards")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShareReport {
|
||||||
|
id String @unique
|
||||||
|
reportId String @unique @db.Uuid
|
||||||
|
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
public Boolean @default(false)
|
||||||
|
password String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("share_reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShareWidget {
|
||||||
|
id String @unique
|
||||||
|
projectId String
|
||||||
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
organizationId String
|
||||||
|
public Boolean @default(true)
|
||||||
|
/// [IPrismaWidgetOptions]
|
||||||
|
options Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("share_widgets")
|
||||||
|
}
|
||||||
|
|
||||||
model EventMeta {
|
model EventMeta {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
name String
|
name String
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ export class Query<T = any> {
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rawHaving(condition: string): this {
|
||||||
|
if (condition) {
|
||||||
|
this._having.push({ condition, operator: 'AND' });
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
andHaving(column: string, operator: Operator, value: SqlParam): this {
|
andHaving(column: string, operator: Operator, value: SqlParam): this {
|
||||||
const condition = this.buildCondition(column, operator, value);
|
const condition = this.buildCondition(column, operator, value);
|
||||||
this._having.push({ condition, operator: 'AND' });
|
this._having.push({ condition, operator: 'AND' });
|
||||||
|
|||||||
@@ -53,9 +53,6 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
|
|||||||
previous: plan.input.previous ?? false,
|
previous: plan.input.previous ?? false,
|
||||||
limit: plan.input.limit,
|
limit: plan.input.limit,
|
||||||
offset: plan.input.offset,
|
offset: plan.input.offset,
|
||||||
criteria: plan.input.criteria,
|
|
||||||
funnelGroup: plan.input.funnelGroup,
|
|
||||||
funnelWindow: plan.input.funnelWindow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { alphabetIds } from '@openpanel/constants';
|
|||||||
import type {
|
import type {
|
||||||
FinalChart,
|
FinalChart,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { chQuery } from '../clickhouse/client';
|
import { chQuery } from '../clickhouse/client';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,7 @@ import type { ConcreteSeries } from './types';
|
|||||||
* Chart Engine - Main entry point
|
* Chart Engine - Main entry point
|
||||||
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
||||||
*/
|
*/
|
||||||
export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
||||||
// Stage 1: Normalize input
|
// Stage 1: Normalize input
|
||||||
const normalized = await normalize(input);
|
const normalized = await normalize(input);
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
|||||||
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
||||||
*/
|
*/
|
||||||
export async function executeAggregateChart(
|
export async function executeAggregateChart(
|
||||||
input: IChartInput,
|
input: IReportInput,
|
||||||
): Promise<FinalChart> {
|
): Promise<FinalChart> {
|
||||||
// Stage 1: Normalize input
|
// Stage 1: Normalize input
|
||||||
const normalized = await normalize(input);
|
const normalized = await normalize(input);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { alphabetIds } from '@openpanel/constants';
|
|||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartInputWithDates,
|
IReportInputWithDates,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { getChartStartEndDate } from '../services/chart.service';
|
import { getChartStartEndDate } from '../services/chart.service';
|
||||||
import { getSettingsForProject } from '../services/organization.service';
|
import { getSettingsForProject } from '../services/organization.service';
|
||||||
@@ -15,8 +15,8 @@ export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
|
|||||||
* Normalize a chart input into a clean structure with dates and normalized series
|
* Normalize a chart input into a clean structure with dates and normalized series
|
||||||
*/
|
*/
|
||||||
export async function normalize(
|
export async function normalize(
|
||||||
input: IChartInput,
|
input: IReportInput,
|
||||||
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
|
): Promise<IReportInputWithDates & { series: SeriesDefinition[] }> {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { startDate, endDate } = getChartStartEndDate(
|
const { startDate, endDate } = getChartStartEndDate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
IChartFormula,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartInputWithDates,
|
IReportInputWithDates,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +50,7 @@ export type ConcreteSeries = {
|
|||||||
export type Plan = {
|
export type Plan = {
|
||||||
concreteSeries: ConcreteSeries[];
|
concreteSeries: ConcreteSeries[];
|
||||||
definitions: SeriesDefinition[];
|
definitions: SeriesDefinition[];
|
||||||
input: IChartInputWithDates;
|
input: IReportInputWithDates;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import sqlstring from 'sqlstring';
|
|||||||
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
||||||
import type {
|
import type {
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IGetChartDataInput,
|
IGetChartDataInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
@@ -973,7 +973,7 @@ export function getChartStartEndDate(
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import type { IChartEvent, IChartBreakdown, IReportInput } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
@@ -16,21 +16,23 @@ export class ConversionService {
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
funnelGroup,
|
options,
|
||||||
funnelWindow = 24,
|
|
||||||
series,
|
series,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
limit,
|
limit,
|
||||||
interval,
|
interval,
|
||||||
timezone,
|
timezone,
|
||||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
}: Omit<IReportInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}) {
|
}) {
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
const breakdownColumns = breakdowns.map(
|
const breakdownColumns = breakdowns.map(
|
||||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
(b: IChartBreakdown, index: number) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((b: IChartBreakdown, index: number) => `b_${index}`);
|
||||||
|
|
||||||
const events = onlyReportEvents(series);
|
const events = onlyReportEvents(series);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ifNaN } from '@openpanel/common';
|
|||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { last, reverse, uniq } from 'ramda';
|
import { last, reverse, uniq } from 'ramda';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
@@ -185,16 +185,19 @@ export class FunnelService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
series,
|
series,
|
||||||
funnelWindow = 24,
|
options,
|
||||||
funnelGroup,
|
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
limit,
|
limit,
|
||||||
timezone = 'UTC',
|
timezone = 'UTC',
|
||||||
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
|
}: IReportInput & { timezone: string; events?: IChartEvent[] }) {
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
|
||||||
const eventSeries = onlyReportEvents(series);
|
const eventSeries = onlyReportEvents(series);
|
||||||
|
|
||||||
if (eventSeries.length === 0) {
|
if (eventSeries.length === 0) {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import type {
|
|||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
ICriteria,
|
IReport,
|
||||||
|
IReportOptions,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||||
@@ -65,17 +65,22 @@ export function transformReportEventItem(
|
|||||||
|
|
||||||
export function transformReport(
|
export function transformReport(
|
||||||
report: DbReport & { layout?: ReportLayout | null },
|
report: DbReport & { layout?: ReportLayout | null },
|
||||||
): IChartProps & { id: string; layout?: ReportLayout | null } {
|
): IReport & {
|
||||||
|
id: string;
|
||||||
|
layout?: ReportLayout | null;
|
||||||
|
} {
|
||||||
|
const options = report.options as IReportOptions | null | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: report.id,
|
id: report.id,
|
||||||
projectId: report.projectId,
|
projectId: report.projectId,
|
||||||
series:
|
name: report.name || 'Untitled',
|
||||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
|
||||||
breakdowns: report.breakdowns as IChartBreakdown[],
|
|
||||||
chartType: report.chartType,
|
chartType: report.chartType,
|
||||||
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
|
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
name: report.name || 'Untitled',
|
series:
|
||||||
|
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||||
|
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||||
range:
|
range:
|
||||||
report.range in deprecated_timeRanges
|
report.range in deprecated_timeRanges
|
||||||
? '30d'
|
? '30d'
|
||||||
@@ -84,10 +89,8 @@ export function transformReport(
|
|||||||
formula: report.formula ?? undefined,
|
formula: report.formula ?? undefined,
|
||||||
metric: report.metric ?? 'sum',
|
metric: report.metric ?? 'sum',
|
||||||
unit: report.unit ?? undefined,
|
unit: report.unit ?? undefined,
|
||||||
criteria: (report.criteria as ICriteria) ?? undefined,
|
|
||||||
funnelGroup: report.funnelGroup ?? undefined,
|
|
||||||
funnelWindow: report.funnelWindow ?? undefined,
|
|
||||||
layout: report.layout ?? undefined,
|
layout: report.layout ?? undefined,
|
||||||
|
options: options ?? 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);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { db } from '../prisma-client';
|
import { db } from '../prisma-client';
|
||||||
|
import { getProjectAccess } from './access.service';
|
||||||
|
|
||||||
export function getShareOverviewById(id: string) {
|
export function getShareOverviewById(id: string) {
|
||||||
return db.shareOverview.findFirst({
|
return db.shareOverview.findFirst({
|
||||||
@@ -18,3 +19,197 @@ export function getShareByProjectId(projectId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dashboard sharing functions
|
||||||
|
export function getShareDashboardById(id: string) {
|
||||||
|
return db.shareDashboard.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
dashboard: {
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareDashboardByDashboardId(dashboardId: string) {
|
||||||
|
return db.shareDashboard.findUnique({
|
||||||
|
where: {
|
||||||
|
dashboardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report sharing functions
|
||||||
|
export function getShareReportById(id: string) {
|
||||||
|
return db.shareReport.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
report: {
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShareReportByReportId(reportId: string) {
|
||||||
|
return db.shareReport.findUnique({
|
||||||
|
where: {
|
||||||
|
reportId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation for secure endpoints
|
||||||
|
export async function validateReportAccess(
|
||||||
|
reportId: string,
|
||||||
|
shareId: string,
|
||||||
|
shareType: 'dashboard' | 'report',
|
||||||
|
) {
|
||||||
|
if (shareType === 'dashboard') {
|
||||||
|
const share = await db.shareDashboard.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: {
|
||||||
|
dashboard: {
|
||||||
|
include: {
|
||||||
|
reports: {
|
||||||
|
where: { id: reportId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share || !share.public) {
|
||||||
|
throw new Error('Share not found or not public');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!share.dashboard.reports.some((r) => r.id === reportId)) {
|
||||||
|
throw new Error('Report does not belong to this dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await db.shareReport.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: {
|
||||||
|
report: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share || !share.public) {
|
||||||
|
throw new Error('Share not found or not public');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (share.reportId !== reportId) {
|
||||||
|
throw new Error('Report ID mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unified validation for share access
|
||||||
|
export async function validateShareAccess(
|
||||||
|
shareId: string,
|
||||||
|
reportId: string,
|
||||||
|
ctx: {
|
||||||
|
cookies: Record<string, string | undefined>;
|
||||||
|
session?: { userId?: string | null };
|
||||||
|
},
|
||||||
|
): Promise<{ projectId: string; isValid: boolean }> {
|
||||||
|
// Check ShareDashboard first
|
||||||
|
const dashboardShare = await db.shareDashboard.findUnique({
|
||||||
|
where: { id: shareId },
|
||||||
|
include: {
|
||||||
|
dashboard: {
|
||||||
|
include: {
|
||||||
|
reports: {
|
||||||
|
where: { id: reportId },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
dashboardShare?.dashboard?.reports &&
|
||||||
|
dashboardShare.dashboard.reports.length > 0
|
||||||
|
) {
|
||||||
|
if (!dashboardShare.public) {
|
||||||
|
throw new Error('Share not found or not public');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = dashboardShare.projectId;
|
||||||
|
|
||||||
|
// If no password is set, share is public and accessible
|
||||||
|
if (!dashboardShare.password) {
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If password is set, require cookie OR member access
|
||||||
|
const hasCookie = !!ctx.cookies[`shared-dashboard-${shareId}`];
|
||||||
|
const hasMemberAccess =
|
||||||
|
ctx.session?.userId &&
|
||||||
|
(await getProjectAccess({
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
projectId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
isValid: hasCookie || !!hasMemberAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ShareReport
|
||||||
|
const reportShare = await db.shareReport.findUnique({
|
||||||
|
where: { id: shareId, reportId },
|
||||||
|
include: {
|
||||||
|
report: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reportShare) {
|
||||||
|
if (!reportShare.public) {
|
||||||
|
throw new Error('Share not found or not public');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = reportShare.projectId;
|
||||||
|
|
||||||
|
// If no password is set, share is public and accessible
|
||||||
|
if (!reportShare.password) {
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
isValid: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If password is set, require cookie OR member access
|
||||||
|
const hasCookie = !!ctx.cookies[`shared-report-${shareId}`];
|
||||||
|
const hasMemberAccess =
|
||||||
|
ctx.session?.userId &&
|
||||||
|
(await getProjectAccess({
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
projectId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
isValid: hasCookie || !!hasMemberAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Share not found');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
IIntegrationConfig,
|
IIntegrationConfig,
|
||||||
INotificationRuleConfig,
|
INotificationRuleConfig,
|
||||||
IProjectFilters,
|
IProjectFilters,
|
||||||
|
IWidgetOptions,
|
||||||
InsightPayload,
|
InsightPayload,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type {
|
import type {
|
||||||
@@ -20,6 +21,7 @@ declare global {
|
|||||||
type IPrismaNotificationPayload = INotificationPayload;
|
type IPrismaNotificationPayload = INotificationPayload;
|
||||||
type IPrismaProjectFilters = IProjectFilters[];
|
type IPrismaProjectFilters = IProjectFilters[];
|
||||||
type IPrismaProjectInsightPayload = InsightPayload;
|
type IPrismaProjectInsightPayload = InsightPayload;
|
||||||
|
type IPrismaWidgetOptions = IWidgetOptions;
|
||||||
type IPrismaClickhouseEvent = IClickhouseEvent;
|
type IPrismaClickhouseEvent = IClickhouseEvent;
|
||||||
type IPrismaClickhouseProfile = IClickhouseProfile;
|
type IPrismaClickhouseProfile = IClickhouseProfile;
|
||||||
type IPrismaClickhouseBotEvent = IClickhouseBotEvent;
|
type IPrismaClickhouseBotEvent = IClickhouseBotEvent;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { sessionRouter } from './routers/session';
|
|||||||
import { shareRouter } from './routers/share';
|
import { shareRouter } from './routers/share';
|
||||||
import { subscriptionRouter } from './routers/subscription';
|
import { subscriptionRouter } from './routers/subscription';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
|
import { widgetRouter } from './routers/widget';
|
||||||
import { createTRPCRouter } from './trpc';
|
import { createTRPCRouter } from './trpc';
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -49,6 +50,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
realtime: realtimeRouter,
|
realtime: realtimeRouter,
|
||||||
chat: chatRouter,
|
chat: chatRouter,
|
||||||
insight: insightRouter,
|
insight: insightRouter,
|
||||||
|
widget: widgetRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -352,8 +352,23 @@ export const authRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.input(zSignInShare)
|
.input(zSignInShare)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { password, shareId } = input;
|
const { password, shareId, shareType = 'overview' } = input;
|
||||||
const share = await getShareOverviewById(input.shareId);
|
|
||||||
|
let share: { password: string | null; public: boolean } | null = null;
|
||||||
|
let cookieName = '';
|
||||||
|
|
||||||
|
if (shareType === 'overview') {
|
||||||
|
share = await getShareOverviewById(shareId);
|
||||||
|
cookieName = `shared-overview-${shareId}`;
|
||||||
|
} else if (shareType === 'dashboard') {
|
||||||
|
const { getShareDashboardById } = await import('@openpanel/db');
|
||||||
|
share = await getShareDashboardById(shareId);
|
||||||
|
cookieName = `shared-dashboard-${shareId}`;
|
||||||
|
} else if (shareType === 'report') {
|
||||||
|
const { getShareReportById } = await import('@openpanel/db');
|
||||||
|
share = await getShareReportById(shareId);
|
||||||
|
cookieName = `shared-report-${shareId}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (!share) {
|
if (!share) {
|
||||||
throw TRPCNotFoundError('Share not found');
|
throw TRPCNotFoundError('Share not found');
|
||||||
@@ -373,7 +388,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
throw TRPCAccessError('Incorrect password');
|
throw TRPCAccessError('Incorrect password');
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.setCookie(`shared-overview-${shareId}`, '1', {
|
ctx.setCookie(cookieName, '1', {
|
||||||
maxAge: 60 * 60 * 24 * 7,
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
...COOKIE_OPTIONS,
|
...COOKIE_OPTIONS,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
clix,
|
clix,
|
||||||
conversionService,
|
conversionService,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
funnelService,
|
funnelService,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
@@ -19,15 +18,16 @@ import {
|
|||||||
getEventFiltersWhereClause,
|
getEventFiltersWhereClause,
|
||||||
getEventMetasCached,
|
getEventMetasCached,
|
||||||
getProfilesCached,
|
getProfilesCached,
|
||||||
|
getReportById,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
onlyReportEvents,
|
onlyReportEvents,
|
||||||
|
sankeyService,
|
||||||
|
validateShareAccess,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
zChartEvent,
|
zReportInput,
|
||||||
zChartEventFilter,
|
|
||||||
zChartInput,
|
|
||||||
zChartSeries,
|
zChartSeries,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zRange,
|
zRange,
|
||||||
@@ -333,124 +333,342 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
funnel: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
funnel: publicProcedure
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
.input(
|
||||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
zReportInput.and(
|
||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
z.object({
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
let chartInput = input;
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
if (input.shareId) {
|
||||||
funnelService.getFunnel({ ...input, ...currentPeriod, timezone }),
|
// Require reportId when shareId provided
|
||||||
input.previous
|
if (!input.reportId) {
|
||||||
? funnelService.getFunnel({ ...input, ...previousPeriod, timezone })
|
throw new Error('reportId required with shareId');
|
||||||
: Promise.resolve(null),
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
// Validate share access
|
||||||
current,
|
const shareValidation = await validateShareAccess(
|
||||||
previous,
|
input.shareId,
|
||||||
};
|
input.reportId,
|
||||||
}),
|
{
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session?.userId
|
||||||
|
? { userId: ctx.session.userId }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!shareValidation.isValid) {
|
||||||
|
throw TRPCAccessError('You do not have access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
// Fetch report and merge date overrides
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const report = await getReportById(input.reportId);
|
||||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
if (!report) {
|
||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
chartInput = {
|
||||||
conversionService.getConversion({ ...input, ...currentPeriod, timezone }),
|
...report,
|
||||||
input.previous
|
// Only allow date overrides
|
||||||
? conversionService.getConversion({
|
range: input.range ?? report.range,
|
||||||
...input,
|
startDate: input.startDate ?? report.startDate,
|
||||||
...previousPeriod,
|
endDate: input.endDate ?? report.endDate,
|
||||||
timezone,
|
interval: input.interval ?? report.interval,
|
||||||
})
|
};
|
||||||
: Promise.resolve(null),
|
} else {
|
||||||
]);
|
// Regular member access check
|
||||||
|
if (!ctx.session?.userId) {
|
||||||
|
throw TRPCAccessError('Authentication required');
|
||||||
|
}
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const { timezone } = await getSettingsForProject(chartInput.projectId);
|
||||||
current: current.map((serie, sIndex) => ({
|
const currentPeriod = getChartStartEndDate(chartInput, timezone);
|
||||||
...serie,
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
data: serie.data.map((d, dIndex) => ({
|
|
||||||
...d,
|
const [current, previous] = await Promise.all([
|
||||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
funnelService.getFunnel({ ...chartInput, ...currentPeriod, timezone }),
|
||||||
|
chartInput.previous
|
||||||
|
? funnelService.getFunnel({
|
||||||
|
...chartInput,
|
||||||
|
...previousPeriod,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
conversion: publicProcedure
|
||||||
|
.input(
|
||||||
|
zReportInput.and(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
let chartInput = input;
|
||||||
|
|
||||||
|
if (input.shareId) {
|
||||||
|
// Require reportId when shareId provided
|
||||||
|
if (!input.reportId) {
|
||||||
|
throw new Error('reportId required with shareId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate share access
|
||||||
|
const shareValidation = await validateShareAccess(
|
||||||
|
input.shareId,
|
||||||
|
input.reportId,
|
||||||
|
{
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session?.userId
|
||||||
|
? { userId: ctx.session.userId }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!shareValidation.isValid) {
|
||||||
|
throw TRPCAccessError('You do not have access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch report and merge date overrides
|
||||||
|
const report = await getReportById(input.reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInput = {
|
||||||
|
...report,
|
||||||
|
// Only allow date overrides
|
||||||
|
range: input.range ?? report.range,
|
||||||
|
startDate: input.startDate ?? report.startDate,
|
||||||
|
endDate: input.endDate ?? report.endDate,
|
||||||
|
interval: input.interval ?? report.interval,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Regular member access check
|
||||||
|
if (!ctx.session?.userId) {
|
||||||
|
throw TRPCAccessError('Authentication required');
|
||||||
|
}
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timezone } = await getSettingsForProject(chartInput.projectId);
|
||||||
|
const currentPeriod = getChartStartEndDate(chartInput, timezone);
|
||||||
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
|
|
||||||
|
const interval = chartInput.interval;
|
||||||
|
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
conversionService.getConversion({
|
||||||
|
...chartInput,
|
||||||
|
...currentPeriod,
|
||||||
|
interval,
|
||||||
|
timezone,
|
||||||
|
}),
|
||||||
|
chartInput.previous
|
||||||
|
? conversionService.getConversion({
|
||||||
|
...chartInput,
|
||||||
|
...previousPeriod,
|
||||||
|
interval,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: current.map((serie, sIndex) => ({
|
||||||
|
...serie,
|
||||||
|
data: serie.data.map((d, dIndex) => ({
|
||||||
|
...d,
|
||||||
|
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||||
|
})),
|
||||||
})),
|
})),
|
||||||
})),
|
previous,
|
||||||
previous,
|
};
|
||||||
};
|
}),
|
||||||
|
|
||||||
|
sankey: protectedProcedure.input(zReportInput).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
|
chart: publicProcedure
|
||||||
// .use(cacher)
|
// .use(cacher)
|
||||||
.input(zChartInput)
|
.input(
|
||||||
|
zReportInput.and(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
if (ctx.session.userId) {
|
let chartInput = input;
|
||||||
|
|
||||||
|
if (input.shareId) {
|
||||||
|
// Require reportId when shareId provided
|
||||||
|
if (!input.reportId) {
|
||||||
|
throw new Error('reportId required with shareId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate share access
|
||||||
|
const shareValidation = await validateShareAccess(
|
||||||
|
input.shareId,
|
||||||
|
input.reportId,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!shareValidation.isValid) {
|
||||||
|
throw TRPCAccessError('You do not have access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch report and merge date overrides
|
||||||
|
const report = await getReportById(input.reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInput = {
|
||||||
|
...report,
|
||||||
|
// Only allow date overrides
|
||||||
|
range: input.range ?? report.range,
|
||||||
|
startDate: input.startDate ?? report.startDate,
|
||||||
|
endDate: input.endDate ?? report.endDate,
|
||||||
|
interval: input.interval ?? report.interval,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Regular member access check
|
||||||
|
if (!ctx.session?.userId) {
|
||||||
|
throw TRPCAccessError('Authentication required');
|
||||||
|
}
|
||||||
const access = await getProjectAccess({
|
const access = await getProjectAccess({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
userId: ctx.session.userId,
|
userId: ctx.session.userId,
|
||||||
});
|
});
|
||||||
if (!access) {
|
if (!access) {
|
||||||
const share = await db.shareOverview.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const share = await db.shareOverview.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use new chart engine
|
return ChartEngine.execute(chartInput);
|
||||||
return ChartEngine.execute(input);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
aggregate: publicProcedure
|
aggregate: publicProcedure
|
||||||
.input(zChartInput)
|
.input(
|
||||||
|
zReportInput.and(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
if (ctx.session.userId) {
|
let chartInput = input;
|
||||||
|
|
||||||
|
if (input.shareId) {
|
||||||
|
// Require reportId when shareId provided
|
||||||
|
if (!input.reportId) {
|
||||||
|
throw new Error('reportId required with shareId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate share access
|
||||||
|
const shareValidation = await validateShareAccess(
|
||||||
|
input.shareId,
|
||||||
|
input.reportId,
|
||||||
|
{
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session?.userId
|
||||||
|
? { userId: ctx.session.userId }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!shareValidation.isValid) {
|
||||||
|
throw TRPCAccessError('You do not have access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch report and merge date overrides
|
||||||
|
const report = await getReportById(input.reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInput = {
|
||||||
|
...report,
|
||||||
|
// Only allow date overrides
|
||||||
|
range: input.range ?? report.range,
|
||||||
|
startDate: input.startDate ?? report.startDate,
|
||||||
|
endDate: input.endDate ?? report.endDate,
|
||||||
|
interval: input.interval ?? report.interval,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Regular member access check
|
||||||
|
if (!ctx.session?.userId) {
|
||||||
|
throw TRPCAccessError('Authentication required');
|
||||||
|
}
|
||||||
const access = await getProjectAccess({
|
const access = await getProjectAccess({
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
userId: ctx.session.userId,
|
userId: ctx.session.userId,
|
||||||
});
|
});
|
||||||
if (!access) {
|
if (!access) {
|
||||||
const share = await db.shareOverview.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const share = await db.shareOverview.findFirst({
|
|
||||||
where: {
|
|
||||||
projectId: input.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!share) {
|
|
||||||
throw TRPCAccessError('You do not have access to this project');
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use aggregate chart engine (optimized for bar/pie charts)
|
return AggregateChartEngine.execute(chartInput);
|
||||||
return AggregateChartEngine.execute(input);
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
cohort: protectedProcedure
|
cohort: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
@@ -461,26 +679,110 @@ export const chartRouter = createTRPCRouter({
|
|||||||
endDate: z.string().nullish(),
|
endDate: z.string().nullish(),
|
||||||
interval: zTimeInterval.default('day'),
|
interval: zTimeInterval.default('day'),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
|
shareId: z.string().optional(),
|
||||||
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
let projectId = input.projectId;
|
||||||
const { projectId, firstEvent, secondEvent } = input;
|
let firstEvent = input.firstEvent;
|
||||||
const dates = getChartStartEndDate(input, timezone);
|
let secondEvent = input.secondEvent;
|
||||||
|
let criteria = input.criteria;
|
||||||
|
let dateRange = input.range;
|
||||||
|
let startDate = input.startDate;
|
||||||
|
let endDate = input.endDate;
|
||||||
|
let interval = input.interval;
|
||||||
|
|
||||||
|
if (input.shareId) {
|
||||||
|
// Require reportId when shareId provided
|
||||||
|
if (!input.reportId) {
|
||||||
|
throw new Error('reportId required with shareId');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate share access
|
||||||
|
const shareValidation = await validateShareAccess(
|
||||||
|
input.shareId,
|
||||||
|
input.reportId,
|
||||||
|
{
|
||||||
|
cookies: ctx.cookies,
|
||||||
|
session: ctx.session?.userId
|
||||||
|
? { userId: ctx.session.userId }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!shareValidation.isValid) {
|
||||||
|
throw TRPCAccessError('You do not have access to this share');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch report and extract events
|
||||||
|
const report = await getReportById(input.reportId);
|
||||||
|
if (!report) {
|
||||||
|
throw TRPCAccessError('Report not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
projectId = report.projectId;
|
||||||
|
const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
|
||||||
|
criteria = retentionOptions?.criteria ?? criteria;
|
||||||
|
dateRange = input.range ?? report.range;
|
||||||
|
startDate = input.startDate ?? report.startDate;
|
||||||
|
endDate = input.endDate ?? report.endDate;
|
||||||
|
interval = input.interval ?? report.interval;
|
||||||
|
|
||||||
|
// Extract events from report series
|
||||||
|
const eventSeries = onlyReportEvents(report.series);
|
||||||
|
const extractedFirstEvent = (
|
||||||
|
eventSeries[0]?.filters?.[0]?.value ?? []
|
||||||
|
).map(String);
|
||||||
|
const extractedSecondEvent = (
|
||||||
|
eventSeries[1]?.filters?.[0]?.value ?? []
|
||||||
|
).map(String);
|
||||||
|
|
||||||
|
if (
|
||||||
|
extractedFirstEvent.length === 0 ||
|
||||||
|
extractedSecondEvent.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error('Report must have at least 2 event series');
|
||||||
|
}
|
||||||
|
|
||||||
|
firstEvent = extractedFirstEvent;
|
||||||
|
secondEvent = extractedSecondEvent;
|
||||||
|
} else {
|
||||||
|
// Regular member access check
|
||||||
|
if (!ctx.session?.userId) {
|
||||||
|
throw TRPCAccessError('Authentication required');
|
||||||
|
}
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timezone } = await getSettingsForProject(projectId);
|
||||||
|
const dates = getChartStartEndDate(
|
||||||
|
{
|
||||||
|
range: dateRange,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
},
|
||||||
|
timezone,
|
||||||
|
);
|
||||||
const diffInterval = {
|
const diffInterval = {
|
||||||
minute: () => differenceInDays(dates.endDate, dates.startDate),
|
minute: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
hour: () => differenceInDays(dates.endDate, dates.startDate),
|
hour: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
day: () => differenceInDays(dates.endDate, dates.startDate),
|
day: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
week: () => differenceInWeeks(dates.endDate, dates.startDate),
|
week: () => differenceInWeeks(dates.endDate, dates.startDate),
|
||||||
month: () => differenceInMonths(dates.endDate, dates.startDate),
|
month: () => differenceInMonths(dates.endDate, dates.startDate),
|
||||||
}[input.interval]();
|
}[interval]();
|
||||||
const sqlInterval = {
|
const sqlInterval = {
|
||||||
minute: 'DAY',
|
minute: 'DAY',
|
||||||
hour: 'DAY',
|
hour: 'DAY',
|
||||||
day: 'DAY',
|
day: 'DAY',
|
||||||
week: 'WEEK',
|
week: 'WEEK',
|
||||||
month: 'MONTH',
|
month: 'MONTH',
|
||||||
}[input.interval];
|
}[interval];
|
||||||
|
|
||||||
const sqlToStartOf = {
|
const sqlToStartOf = {
|
||||||
minute: 'toDate',
|
minute: 'toDate',
|
||||||
@@ -488,9 +790,9 @@ export const chartRouter = createTRPCRouter({
|
|||||||
day: 'toDate',
|
day: 'toDate',
|
||||||
week: 'toStartOfWeek',
|
week: 'toStartOfWeek',
|
||||||
month: 'toStartOfMonth',
|
month: 'toStartOfMonth',
|
||||||
}[input.interval];
|
}[interval];
|
||||||
|
|
||||||
const countCriteria = input.criteria === 'on_or_after' ? '>=' : '=';
|
const countCriteria = criteria === 'on_or_after' ? '>=' : '=';
|
||||||
|
|
||||||
const usersSelect = range(0, diffInterval + 1)
|
const usersSelect = range(0, diffInterval + 1)
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
|
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
|
||||||
import { zReportInput } from '@openpanel/validation';
|
import { zReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { getProjectAccess } from '../access';
|
import { getProjectAccess } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
@@ -21,7 +21,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
report: zReportInput.omit({ projectId: true }),
|
report: zReport.omit({ projectId: true }),
|
||||||
dashboardId: z.string(),
|
dashboardId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -55,10 +55,8 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
options: report.options,
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -66,7 +64,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
reportId: z.string(),
|
reportId: z.string(),
|
||||||
report: zReportInput.omit({ projectId: true }),
|
report: zReport.omit({ projectId: true }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input: { report, reportId }, ctx }) => {
|
.mutation(async ({ input: { report, reportId }, ctx }) => {
|
||||||
@@ -100,10 +98,8 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
options: report.options,
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -171,10 +167,8 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous,
|
previous: report.previous,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric,
|
metric: report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
options: report.options,
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import ShortUniqueId from 'short-unique-id';
|
import ShortUniqueId from 'short-unique-id';
|
||||||
|
|
||||||
import { db } from '@openpanel/db';
|
import {
|
||||||
import { zShareOverview } from '@openpanel/validation';
|
db,
|
||||||
|
getReportById,
|
||||||
|
getReportsByDashboardId,
|
||||||
|
getShareDashboardById,
|
||||||
|
getShareReportById,
|
||||||
|
transformReport,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
import {
|
||||||
|
zShareDashboard,
|
||||||
|
zShareOverview,
|
||||||
|
zShareReport,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { hashPassword } from '@openpanel/auth';
|
import { hashPassword } from '@openpanel/auth';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { TRPCNotFoundError } from '../errors';
|
import { getProjectAccess } from '../access';
|
||||||
|
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
const uid = new ShortUniqueId({ length: 6 });
|
const uid = new ShortUniqueId({ length: 6 });
|
||||||
@@ -85,4 +97,203 @@ export const shareRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Dashboard sharing
|
||||||
|
dashboard: publicProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
dashboardId: z.string(),
|
||||||
|
})
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const share = await db.shareDashboard.findUnique({
|
||||||
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dashboard: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where:
|
||||||
|
'dashboardId' in input
|
||||||
|
? {
|
||||||
|
dashboardId: input.dashboardId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: input.shareId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
if ('shareId' in input) {
|
||||||
|
throw TRPCNotFoundError('Dashboard share not found');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...share,
|
||||||
|
hasAccess: !!ctx.cookies[`shared-dashboard-${share?.id}`],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createDashboard: protectedProcedure
|
||||||
|
.input(zShareDashboard)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = input.password
|
||||||
|
? await hashPassword(input.password)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return db.shareDashboard.upsert({
|
||||||
|
where: {
|
||||||
|
dashboardId: input.dashboardId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: uid.rnd(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
dashboardId: input.dashboardId,
|
||||||
|
public: input.public,
|
||||||
|
password: passwordHash,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
public: input.public,
|
||||||
|
password: passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
dashboardReports: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const share = await getShareDashboardById(input.shareId);
|
||||||
|
|
||||||
|
if (!share || !share.public) {
|
||||||
|
throw TRPCNotFoundError('Dashboard share not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check password access
|
||||||
|
const hasAccess = !!ctx.cookies[`shared-dashboard-${share.id}`];
|
||||||
|
if (share.password && !hasAccess) {
|
||||||
|
throw TRPCAccessError('Password required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return getReportsByDashboardId(share.dashboardId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Report sharing
|
||||||
|
report: publicProcedure
|
||||||
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
reportId: z.string(),
|
||||||
|
})
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const share = await db.shareReport.findUnique({
|
||||||
|
include: {
|
||||||
|
organization: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
report: true,
|
||||||
|
},
|
||||||
|
where:
|
||||||
|
'reportId' in input
|
||||||
|
? {
|
||||||
|
reportId: input.reportId,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: input.shareId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
if ('shareId' in input) {
|
||||||
|
throw TRPCNotFoundError('Report share not found');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...share,
|
||||||
|
hasAccess: !!ctx.cookies[`shared-report-${share?.id}`],
|
||||||
|
report: transformReport(share.report),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
createReport: protectedProcedure
|
||||||
|
.input(zShareReport)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const access = await getProjectAccess({
|
||||||
|
projectId: input.projectId,
|
||||||
|
userId: ctx.session.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
throw TRPCAccessError('You do not have access to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = input.password
|
||||||
|
? await hashPassword(input.password)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return db.shareReport.upsert({
|
||||||
|
where: {
|
||||||
|
reportId: input.reportId,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: uid.rnd(),
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
projectId: input.projectId,
|
||||||
|
reportId: input.reportId,
|
||||||
|
public: input.public,
|
||||||
|
password: passwordHash,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
public: input.public,
|
||||||
|
password: passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
298
packages/trpc/src/routers/widget.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import ShortUniqueId from 'short-unique-id';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TABLE_NAMES,
|
||||||
|
ch,
|
||||||
|
clix,
|
||||||
|
db,
|
||||||
|
eventBuffer,
|
||||||
|
getSettingsForProject,
|
||||||
|
} from '@openpanel/db';
|
||||||
|
import {
|
||||||
|
zCounterWidgetOptions,
|
||||||
|
zRealtimeWidgetOptions,
|
||||||
|
zWidgetOptions,
|
||||||
|
zWidgetType,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { TRPCNotFoundError } from '../errors';
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
|
const uid = new ShortUniqueId({ length: 6 });
|
||||||
|
|
||||||
|
// Helper to find widget by projectId and type
|
||||||
|
async function findWidgetByType(projectId: string, type: string) {
|
||||||
|
const widgets = await db.shareWidget.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
});
|
||||||
|
return widgets.find(
|
||||||
|
(w) => (w.options as z.infer<typeof zWidgetOptions>)?.type === type,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const widgetRouter = createTRPCRouter({
|
||||||
|
// Get widget by projectId and type (returns null if not found or not public)
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(z.object({ projectId: z.string(), type: zWidgetType }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const widget = await findWidgetByType(input.projectId, input.type);
|
||||||
|
|
||||||
|
if (!widget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Toggle widget public status (creates if doesn't exist)
|
||||||
|
toggle: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
type: zWidgetType,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const existing = await findWidgetByType(input.projectId, input.type);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return db.shareWidget.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { public: input.enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new widget with default options
|
||||||
|
const defaultOptions =
|
||||||
|
input.type === 'realtime'
|
||||||
|
? {
|
||||||
|
type: 'realtime' as const,
|
||||||
|
referrers: true,
|
||||||
|
countries: true,
|
||||||
|
paths: false,
|
||||||
|
}
|
||||||
|
: { type: 'counter' as const };
|
||||||
|
|
||||||
|
return db.shareWidget.create({
|
||||||
|
data: {
|
||||||
|
id: uid.rnd(),
|
||||||
|
projectId: input.projectId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
public: input.enabled,
|
||||||
|
options: defaultOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Update widget options (for realtime widget)
|
||||||
|
updateOptions: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
organizationId: z.string(),
|
||||||
|
options: zWidgetOptions,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const existing = await findWidgetByType(
|
||||||
|
input.projectId,
|
||||||
|
input.options.type,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return db.shareWidget.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { options: input.options },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new widget if it doesn't exist
|
||||||
|
return db.shareWidget.create({
|
||||||
|
data: {
|
||||||
|
id: uid.rnd(),
|
||||||
|
projectId: input.projectId,
|
||||||
|
organizationId: input.organizationId,
|
||||||
|
public: false,
|
||||||
|
options: input.options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
|
counter: publicProcedure
|
||||||
|
.input(z.object({ shareId: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const widget = await db.shareWidget.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.shareId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget || !widget.public) {
|
||||||
|
throw TRPCNotFoundError('Widget not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.options.type !== 'counter') {
|
||||||
|
throw TRPCNotFoundError('Invalid widget type');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: widget.projectId,
|
||||||
|
counter: await eventBuffer.getActiveVisitorCount(widget.projectId),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
realtimeData: publicProcedure
|
||||||
|
.input(z.object({ shareId: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
// Validate ShareWidget exists and is public
|
||||||
|
const widget = await db.shareWidget.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.shareId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
domain: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget || !widget.public) {
|
||||||
|
throw TRPCNotFoundError('Widget not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId, options } = widget;
|
||||||
|
|
||||||
|
if (options.type !== 'realtime') {
|
||||||
|
throw TRPCNotFoundError('Invalid widget type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { timezone } = await getSettingsForProject(projectId);
|
||||||
|
|
||||||
|
// Always fetch live count and histogram
|
||||||
|
const totalSessionsQuery = clix(ch, timezone)
|
||||||
|
.select<{ total_sessions: number }>([
|
||||||
|
'uniq(session_id) as total_sessions',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
|
||||||
|
|
||||||
|
const minuteCountsQuery = clix(ch, timezone)
|
||||||
|
.select<{
|
||||||
|
minute: string;
|
||||||
|
session_count: number;
|
||||||
|
visitor_count: number;
|
||||||
|
}>([
|
||||||
|
`${clix.toStartOf('created_at', 'minute')} as minute`,
|
||||||
|
'uniq(session_id) as session_count',
|
||||||
|
'uniq(profile_id) as visitor_count',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||||
|
.groupBy(['minute'])
|
||||||
|
.orderBy('minute', 'ASC')
|
||||||
|
.fill(
|
||||||
|
clix.exp('toStartOfMinute(now() - INTERVAL 30 MINUTE)'),
|
||||||
|
clix.exp('toStartOfMinute(now())'),
|
||||||
|
clix.exp('INTERVAL 1 MINUTE'),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Conditionally fetch countries
|
||||||
|
const countriesQueryPromise = options.countries
|
||||||
|
? clix(ch, timezone)
|
||||||
|
.select<{
|
||||||
|
country: string;
|
||||||
|
count: number;
|
||||||
|
}>(['country', 'uniq(session_id) as count'])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||||
|
.where('country', '!=', '')
|
||||||
|
.where('country', 'IS NOT NULL')
|
||||||
|
.groupBy(['country'])
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(10)
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve<Array<{ country: string; count: number }>>([]);
|
||||||
|
|
||||||
|
// Conditionally fetch referrers
|
||||||
|
const referrersQueryPromise = options.referrers
|
||||||
|
? clix(ch, timezone)
|
||||||
|
.select<{ referrer: string; count: number }>([
|
||||||
|
'referrer_name as referrer',
|
||||||
|
'uniq(session_id) as count',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||||
|
.where('referrer_name', '!=', '')
|
||||||
|
.where('referrer_name', 'IS NOT NULL')
|
||||||
|
.groupBy(['referrer_name'])
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(10)
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve<Array<{ referrer: string; count: number }>>([]);
|
||||||
|
|
||||||
|
// Conditionally fetch paths
|
||||||
|
const pathsQueryPromise = options.paths
|
||||||
|
? clix(ch, timezone)
|
||||||
|
.select<{ path: string; count: number }>([
|
||||||
|
'path',
|
||||||
|
'uniq(session_id) as count',
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
|
||||||
|
.where('path', '!=', '')
|
||||||
|
.where('path', 'IS NOT NULL')
|
||||||
|
.groupBy(['path'])
|
||||||
|
.orderBy('count', 'DESC')
|
||||||
|
.limit(10)
|
||||||
|
.execute()
|
||||||
|
: Promise.resolve<Array<{ path: string; count: number }>>([]);
|
||||||
|
|
||||||
|
const [totalSessions, minuteCounts, countries, referrers, paths] =
|
||||||
|
await Promise.all([
|
||||||
|
totalSessionsQuery.execute(),
|
||||||
|
minuteCountsQuery.execute(),
|
||||||
|
countriesQueryPromise,
|
||||||
|
referrersQueryPromise,
|
||||||
|
pathsQueryPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
liveCount: totalSessions[0]?.total_sessions || 0,
|
||||||
|
project: widget.project,
|
||||||
|
histogram: minuteCounts.map((item) => ({
|
||||||
|
minute: item.minute,
|
||||||
|
sessionCount: item.session_count,
|
||||||
|
visitorCount: item.visitor_count,
|
||||||
|
timestamp: new Date(item.minute).getTime(),
|
||||||
|
time: new Date(item.minute).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
countries: countries.map((item) => ({
|
||||||
|
country: item.country,
|
||||||
|
count: item.count,
|
||||||
|
})),
|
||||||
|
referrers: referrers.map((item) => ({
|
||||||
|
referrer: item.referrer,
|
||||||
|
count: item.count,
|
||||||
|
})),
|
||||||
|
paths: paths.map((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
count: item.count,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||