dashboard: add retention and quick fix loading states

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-05-01 14:15:31 +02:00
parent c3815bf6ab
commit 5e743a3502
52 changed files with 1324 additions and 205 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ packages/sdk/test.ts
dump.sql
dump-*
.sql
clickhouse
./clickhouse
# Logs

View File

@@ -26,8 +26,9 @@ export default async function Page({
}
return (
<PageLayout title={dashboard.name} organizationSlug={organizationSlug}>
<>
<PageLayout title={dashboard.name} organizationSlug={organizationSlug} />
<ListReports reports={reports} />
</PageLayout>
</>
);
}

View File

@@ -1,105 +0,0 @@
'use client';
import { Card, CardActions, CardActionsItem } from '@/components/card';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { api, handleErrorToastOptions } from '@/trpc/client';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceDashboards } from '@openpanel/db';
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useAppParams();
const { organizationSlug, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
},
})(error);
},
onSuccess() {
router.refresh();
toast('Success', {
description: 'Dashboard deleted.',
});
},
});
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return (
<>
<div className="grid gap-4 p-4 sm:grid-cols-2">
{dashboards.map((item) => (
<Card key={item.id} hover>
<div>
<Link
prefetch={false}
href={`/${organizationSlug}/${projectId}/dashboards/${item.id}`}
className="flex flex-col p-4"
>
<span className="font-medium">{item.name}</span>
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="w-full text-destructive" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
))}
</div>
</>
);
}

View File

@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
export function HeaderDashboards() {
return (

View File

@@ -0,0 +1,27 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import FullPageLoadingState from '@/components/full-page-loading-state';
import withSuspense from '@/hocs/with-suspense';
import type { LucideIcon } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import { getDashboardsByProjectId } from '@openpanel/db';
import { HeaderDashboards } from './header';
import { ListDashboards } from './list-dashboards';
interface Props {
projectId: string;
}
const ListDashboardsServer = async ({ projectId }: Props) => {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</>
);
};
export default withSuspense(ListDashboardsServer, FullPageLoadingState);

View File

@@ -0,0 +1,177 @@
'use client';
import { Card, CardActions, CardActionsItem } from '@/components/card';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { api, handleErrorToastOptions } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { format } from 'date-fns';
import {
AreaChartIcon,
BarChart3Icon,
BarChartHorizontalIcon,
ConeIcon,
Globe2Icon,
HashIcon,
LayoutPanelTopIcon,
LineChartIcon,
Pencil,
PieChartIcon,
PlusIcon,
SquarePenIcon,
Trash,
} from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceDashboards } from '@openpanel/db';
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}
export function ListDashboards({ dashboards }: ListDashboardsProps) {
const router = useRouter();
const params = useAppParams();
const { organizationSlug, projectId } = params;
const deletion = api.dashboard.delete.useMutation({
onError: (error, variables) => {
return handleErrorToastOptions({
action: {
label: 'Force delete',
onClick: () => {
deletion.mutate({
forceDelete: true,
id: variables.id,
});
},
},
})(error);
},
onSuccess() {
router.refresh();
toast('Success', {
description: 'Dashboard deleted.',
});
},
});
if (dashboards.length === 0) {
return (
<FullPageEmptyState title="No dashboards" icon={LayoutPanelTopIcon}>
<p>You have not created any dashboards for this project yet</p>
<Button
onClick={() => pushModal('AddDashboard')}
className="mt-14"
icon={PlusIcon}
>
Create dashboard
</Button>
</FullPageEmptyState>
);
}
return (
<>
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-3">
{dashboards.map((item) => {
const visibleReports = item.reports.slice(
0,
item.reports.length > 6 ? 5 : 6
);
return (
<Card key={item.id} hover>
<div>
<Link
prefetch={false}
href={`/${organizationSlug}/${projectId}/dashboards/${item.id}`}
className="flex flex-col p-4 @container"
>
<div>
<div className="font-medium">{item.name}</div>
<div className="text-xs text-muted-foreground">
{item.updatedAt.toLocaleString()}
</div>
</div>
<div
className={cn(
'mt-2 grid gap-4',
'grid-cols-2 @xs:grid-cols-3 @lg:grid-cols-4'
)}
>
{visibleReports.map((report) => {
const Icon = {
bar: BarChartHorizontalIcon,
linear: LineChartIcon,
pie: PieChartIcon,
metric: HashIcon,
map: Globe2Icon,
histogram: BarChart3Icon,
funnel: ConeIcon,
area: AreaChartIcon,
}[report.chartType];
return (
<div
className="flex flex-col items-center justify-center rounded-xl bg-slate-50 p-4"
key={report.id}
>
<Icon size={24} className="text-blue-600" />
<div className="mt-2 w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-xs">
{report.name}
</div>
</div>
);
})}
{item.reports.length > 6 && (
<div className="flex flex-col items-center justify-center rounded-xl bg-slate-50 p-4">
<PlusIcon size={24} className="text-blue-600" />
<div className="mt-2 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs">
{item.reports.length - 5} more
</div>
</div>
)}
</div>
{/* <span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-muted-foreground">
<span className="mr-2 font-medium">
{item.reports.length} reports
</span>
{item.reports.map((item) => item.name).join(', ')}
</span> */}
</Link>
</div>
<CardActions>
<CardActionsItem className="w-full" asChild>
<button
onClick={() => {
pushModal('EditDashboard', item);
}}
>
<Pencil size={16} />
Edit
</button>
</CardActionsItem>
<CardActionsItem className="w-full text-destructive" asChild>
<button
onClick={() => {
deletion.mutate({
id: item.id,
});
}}
>
<Trash size={16} />
Delete
</button>
</CardActionsItem>
</CardActions>
</Card>
);
})}
</div>
</>
);
}

View File

@@ -1,9 +1,6 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { getDashboardsByProjectId } from '@openpanel/db';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';
import ListDashboardsServer from './list-dashboards';
interface PageProps {
params: {
@@ -12,15 +9,13 @@ interface PageProps {
};
}
export default async function Page({
export default function Page({
params: { projectId, organizationSlug },
}: PageProps) {
const dashboards = await getDashboardsByProjectId(projectId);
return (
<PageLayout title="Dashboards" organizationSlug={organizationSlug}>
{dashboards.length > 0 && <HeaderDashboards />}
<ListDashboards dashboards={dashboards} />
</PageLayout>
<>
<PageLayout title="Dashboards" organizationSlug={organizationSlug} />
<ListDashboardsServer projectId={projectId} />
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -56,7 +56,8 @@ export default async function Page({
]);
return (
<PageLayout title="Events" organizationSlug={organizationSlug}>
<>
<PageLayout title="Events" organizationSlug={organizationSlug} />
<StickyBelowHeader className="flex justify-between p-4">
<OverviewFiltersDrawer
mode="events"
@@ -82,6 +83,6 @@ export default async function Page({
<EventConversionsListServer projectId={projectId} />
</div>
</div>
</PageLayout>
</>
);
}

View File

@@ -12,6 +12,7 @@ import {
KeySquareIcon,
LayoutPanelTopIcon,
UserIcon,
UserSearchIcon,
UsersIcon,
WallpaperIcon,
WarehouseIcon,
@@ -96,6 +97,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
label="Events"
href={`/${params.organizationSlug}/${projectId}/events`}
/>
<LinkWithIcon
icon={UserSearchIcon}
label="Retention"
href={`/${params.organizationSlug}/${projectId}/retention`}
/>
<LinkWithIcon
icon={UsersIcon}
label="Profiles"

View File

@@ -38,7 +38,7 @@ export function LayoutSidebar({
<button
onClick={() => setActive(false)}
className={cn(
'fixed bottom-0 left-0 right-0 top-0 z-30 backdrop-blur-sm transition-opacity',
'fixed bottom-0 left-0 right-0 top-0 z-50 backdrop-blur-sm transition-opacity',
active
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
@@ -46,7 +46,7 @@ export function LayoutSidebar({
/>
<div
className={cn(
'fixed left-0 top-0 z-30 flex h-screen w-72 flex-col border-r border-border bg-background transition-transform',
'fixed left-0 top-0 z-50 flex h-screen w-72 flex-col border-r border-border bg-background transition-transform',
'-translate-x-72 lg:-translate-x-0', // responsive
active && 'translate-x-0' // force active on mobile
)}

View File

@@ -1,23 +1,16 @@
import DarkModeToggle from '@/components/dark-mode-toggle';
import withSuspense from '@/hocs/with-suspense';
import {
getCurrentProjects,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
import { getCurrentProjects } from '@openpanel/db';
import LayoutProjectSelector from './layout-project-selector';
interface PageLayoutProps {
children: React.ReactNode;
title: React.ReactNode;
organizationSlug: string;
}
export default async function PageLayout({
children,
title,
organizationSlug,
}: PageLayoutProps) {
async function PageLayout({ title, organizationSlug }: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
return (
@@ -31,7 +24,16 @@ export default async function PageLayout({
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
</div>
</div>
<div>{children}</div>
</>
);
}
const Loading = ({ title }: PageLayoutProps) => (
<>
<div className="sticky top-0 z-20 flex h-16 flex-shrink-0 items-center justify-between border-b border-border bg-background px-4 pl-12 lg:pl-4">
<div className="text-xl font-medium">{title}</div>
</div>
</>
);
export default withSuspense(PageLayout, Loading);

View File

@@ -3,15 +3,13 @@ import { OverviewFiltersButtons } from '@/components/overview/filters/overview-f
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewShareServer from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { getShareByProjectId } from '@openpanel/db';
import OverviewMetrics from '../../../../components/overview/overview-metrics';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { OverviewReportRange } from './overview-sticky-header';
@@ -23,13 +21,12 @@ interface PageProps {
};
}
export default async function Page({
export default function Page({
params: { organizationSlug, projectId },
}: PageProps) {
const share = await getShareByProjectId(projectId);
return (
<PageLayout title="Overview" organizationSlug={organizationSlug}>
<>
<PageLayout title="Overview" organizationSlug={organizationSlug} />
<StickyBelowHeader>
<div className="flex justify-between gap-2 p-4">
<div className="flex gap-2">
@@ -38,12 +35,12 @@ export default async function Page({
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
<OverviewShareServer projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="grid grid-cols-6 gap-4 p-4">
{/* <div className="grid grid-cols-6 gap-4 p-4">
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
@@ -53,7 +50,7 @@ export default async function Page({
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
</div>
</PageLayout>
</div> */}
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -124,15 +124,16 @@ export default async function Page({
}
return (
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</div>
}
>
<>
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex items-center gap-2">
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
{getProfileName(profile)}
</div>
}
/>
{/* <StickyBelowHeader className="flex justify-between p-4">
<OverviewFiltersDrawer
projectId={projectId}
@@ -189,7 +190,7 @@ export default async function Page({
</div>
</div>
</div>
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -29,7 +29,8 @@ export default function Page({
searchParams: { cursor, f },
}: PageProps) {
return (
<PageLayout title="Profiles" organizationSlug={organizationSlug}>
<>
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
{/* <StickyBelowHeader className="flex justify-between p-4">
<OverviewFiltersDrawer
projectId={projectId}
@@ -57,6 +58,6 @@ export default function Page({
/>
</div>
</div>
</PageLayout>
</>
);
}

View File

@@ -24,16 +24,17 @@ export default async function Page({
}
return (
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex cursor-pointer items-center gap-2">
{report.name}
<Pencil size={16} />
</div>
}
>
<>
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex cursor-pointer items-center gap-2">
{report.name}
<Pencil size={16} />
</div>
}
/>
<ReportEditor report={report} />
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -12,16 +12,17 @@ interface PageProps {
export default function Page({ params: { organizationSlug } }: PageProps) {
return (
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex cursor-pointer items-center gap-2">
Unnamed report
<Pencil size={16} />
</div>
}
>
<>
<PageLayout
organizationSlug={organizationSlug}
title={
<div className="flex cursor-pointer items-center gap-2">
Unnamed report
<Pencil size={16} />
</div>
}
/>
<ReportEditor report={null} />
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,109 @@
'use client';
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
import { useNumber } from '@/hooks/useNumerFormatter';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
XAxis,
YAxis,
} from 'recharts';
type Props = {
data: { users: number; days: number }[];
};
function Tooltip(props: any) {
const payload = props.payload?.[0]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background p-3 text-sm shadow-xl">
<div>
<div className="text-xs text-muted-foreground">
Days since last seen
</div>
<div className="text-lg font-semibold">{payload.days}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Active users</div>
<div className="text-lg font-semibold">{payload.users}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const max = Math.max(...data.map((d) => d.users));
const number = useNumber();
return (
<div className="p-4">
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart data={data} width={width} height={height}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="users"
stroke={getChartColor(0)}
strokeWidth={2}
fill={`url(#bg)`}
/>
<XAxis
dataKey="days"
axisLine={false}
fontSize={12}
// type="number"
tickLine={false}
label={{
value: 'DAYS',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
label={{
value: 'USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
dataKey="users"
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -0,0 +1,25 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionLastSeenSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const LastActiveUsersServer = async ({ projectId }: Props) => {
const res = await getRetentionLastSeenSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Last time in days a user was active</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(LastActiveUsersServer);

View File

@@ -0,0 +1,66 @@
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { AlertCircleIcon } from 'lucide-react';
import PageLayout from '../page-layout';
import LastActiveUsersServer from './last-active-users';
import RollingActiveUsers from './rolling-active-users';
import UsersRetentionSeries from './users-retention-series';
import WeeklyCohortsServer from './weekly-cohorts';
type Props = {
params: {
organizationSlug: string;
projectId: string;
};
};
const Retention = ({ params: { projectId, organizationSlug } }: Props) => {
return (
<>
<PageLayout title="Retention" organizationSlug={organizationSlug} />
<div className="flex flex-col gap-8 p-8">
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Experimental feature</AlertTitle>
<AlertDescription>
<p>
This page is an experimental feature and we&apos;ll be working
hard to make it even better. Stay tuned!
</p>
<p>
Please DM me on{' '}
<a
href="https://go.openpanel.dev/discord"
className="font-medium underline"
>
Discord
</a>{' '}
or{' '}
<a
href="https://twitter.com/CarlLindesvard"
className="font-medium underline"
>
X/Twitter
</a>{' '}
if you notice any issues.
</p>
</AlertDescription>
</Alert>
<RollingActiveUsers projectId={projectId} />
<Alert>
<AlertCircleIcon size={18} />
<AlertTitle>Retention info</AlertTitle>
<AlertDescription>
This information is only relevant if you supply a user ID to the
SDK!
</AlertDescription>
</Alert>
<LastActiveUsersServer projectId={projectId} />
<UsersRetentionSeries projectId={projectId} />
<WeeklyCohortsServer projectId={projectId} />
</div>
</>
);
};
export default Retention;

View File

@@ -0,0 +1,155 @@
'use client';
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
import { useNumber } from '@/hooks/useNumerFormatter';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
XAxis,
YAxis,
} from 'recharts';
import type { IServiceRetentionRollingActiveUsers } from '@openpanel/db';
type Props = {
data: {
daily: IServiceRetentionRollingActiveUsers[];
weekly: IServiceRetentionRollingActiveUsers[];
monthly: IServiceRetentionRollingActiveUsers[];
};
};
function Tooltip(props: any) {
const payload = props.payload?.[2]?.payload;
if (!payload) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background p-3 text-sm shadow-xl">
<div className="text-xs text-muted-foreground">{payload.date}</div>
<div>
<div className="text-xs text-muted-foreground">
Monthly active users
</div>
<div className="text-lg font-semibold text-chart-2">{payload.mau}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Weekly active users</div>
<div className="text-lg font-semibold text-chart-1">{payload.wau}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Daily active users</div>
<div className="text-lg font-semibold text-chart-0">{payload.dau}</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const max = Math.max(...data.monthly.map((d) => d.users));
const number = useNumber();
const rechartData = data.daily.map((d) => ({
date: d.date,
dau: d.users,
wau: data.weekly.find((w) => w.date === d.date)?.users,
mau: data.monthly.find((m) => m.date === d.date)?.users,
}));
return (
<div className="p-4">
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart data={rechartData} width={width} height={height}>
<defs>
<linearGradient id="dau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
></stop>
</linearGradient>
<linearGradient id="wau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(1)}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={getChartColor(1)}
stopOpacity={0.1}
></stop>
</linearGradient>
<linearGradient id="mau" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(2)}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={getChartColor(2)}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="dau"
stroke={getChartColor(0)}
strokeWidth={2}
fill={`url(#dau)`}
/>
<Area
dataKey="wau"
stroke={getChartColor(1)}
strokeWidth={2}
fill={`url(#wau)`}
/>
<Area
dataKey="mau"
stroke={getChartColor(2)}
strokeWidth={2}
fill={`url(#mau)`}
/>
<XAxis
dataKey="date"
axisLine={false}
fontSize={12}
// type="number"
tickLine={false}
/>
<YAxis
label={{
value: 'UNIQUE USERS',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -0,0 +1,35 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRollingActiveUsers } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const RollingActiveUsersServer = async ({ projectId }: Props) => {
const series = await Promise.all([
await getRollingActiveUsers({ projectId, days: 1 }),
await getRollingActiveUsers({ projectId, days: 7 }),
await getRollingActiveUsers({ projectId, days: 30 }),
]);
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Rolling active users</span>
</WidgetHead>
<Chart
data={{
daily: series[0],
weekly: series[1],
monthly: series[2],
}}
/>
</Widget>
);
};
export default withLoadingWidget(RollingActiveUsersServer);

View File

@@ -0,0 +1,126 @@
'use client';
import { getYAxisWidth } from '@/components/report/chart/chart-utils';
import { ResponsiveContainer } from '@/components/report/chart/ResponsiveContainer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { formatDate } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
Tooltip as RechartTooltip,
XAxis,
YAxis,
} from 'recharts';
import { round } from '@openpanel/common';
type Props = {
data: {
date: string;
active_users: number;
retained_users: number;
retention: number;
}[];
};
function Tooltip({ payload }: any) {
const { date, active_users, retained_users, retention } =
payload?.[0]?.payload || {};
const formatDate = useFormatDateInterval('day');
if (!date) {
return null;
}
return (
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background p-3 text-sm shadow-xl">
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(date))}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Active Users</div>
<div className="text-lg font-semibold">{active_users}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Retained Users</div>
<div className="text-lg font-semibold">{retained_users}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Retention</div>
<div className="text-lg font-semibold">{round(retention, 2)}%</div>
</div>
</div>
);
}
const Chart = ({ data }: Props) => {
const max = Math.max(...data.map((d) => d.retention));
const number = useNumber();
console.log('data', data);
return (
<div className="p-4">
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart data={data} width={width} height={height}>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={getChartColor(0)}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={getChartColor(0)}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<RechartTooltip content={<Tooltip />} />
<Area
dataKey="retention"
stroke={getChartColor(0)}
strokeWidth={2}
fill={`url(#bg)`}
/>
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(new Date(m))}
tickLine={false}
allowDuplicatedCategory={false}
label={{
value: 'DATE',
position: 'insideBottom',
offset: 0,
fontSize: 10,
}}
/>
<YAxis
label={{
value: 'RETENTION (%)',
angle: -90,
position: 'insideLeft',
offset: 0,
fontSize: 10,
}}
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(max)}
allowDecimals={false}
domain={[0, max]}
tickFormatter={number.short}
/>
</AreaChart>
)}
</ResponsiveContainer>
</div>
);
};
export default Chart;

View File

@@ -0,0 +1,25 @@
import { Widget, WidgetHead } from '@/components/widget';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { getRetentionSeries } from '@openpanel/db';
import Chart from './chart';
type Props = {
projectId: string;
};
const UsersRetentionSeries = async ({ projectId }: Props) => {
const res = await getRetentionSeries({ projectId });
return (
<Widget className="w-full">
<WidgetHead>
<span className="title">Stickyness / Retention (%)</span>
</WidgetHead>
<Chart data={res} />
</Widget>
);
};
export default withLoadingWidget(UsersRetentionSeries);

View File

@@ -0,0 +1,132 @@
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTableHead } from '@/components/widget-table';
import withLoadingWidget from '@/hocs/with-loading-widget';
import { cn } from '@/utils/cn';
import { getRetentionCohortTable } from '@openpanel/db';
type Props = {
projectId: string;
};
const Cell = ({ value, ratio }: { value: number; ratio: number }) => {
return (
<td
className={cn('relative h-8 border', ratio !== 0 && 'border-background')}
>
<div
className="absolute inset-0 z-0 bg-blue-600"
style={{ opacity: ratio }}
></div>
<div className="relative z-10">{value}</div>
</td>
);
};
const WeeklyCohortsServer = async ({ projectId }: Props) => {
const res = await getRetentionCohortTable({ projectId });
const minValue = 0;
const maxValue = Math.max(
...res.flatMap((row) => [
row.period_0,
row.period_1,
row.period_2,
row.period_3,
row.period_4,
row.period_5,
row.period_6,
row.period_7,
row.period_8,
row.period_9,
])
);
const calculateRatio = (currentValue: number) =>
currentValue === 0
? 0
: Math.max(
0.1,
Math.min(1, (currentValue - minValue) / (maxValue - minValue))
);
return (
<Widget>
<WidgetHead>
<span className="title">Weekly Cohorts</span>
</WidgetHead>
<div className="overflow-hidden rounded-b-xl">
<div className="-m-px">
<table className="w-full table-fixed border-collapse text-center">
<WidgetTableHead className="[&_th]:border-b-2 [&_th]:!text-center">
<tr>
<th>Week</th>
<th>0</th>
<th>1</th>
<th>2</th>
<th>3</th>
<th>4</th>
<th>5</th>
<th>6</th>
<th>7</th>
<th>8</th>
<th>9</th>
</tr>
</WidgetTableHead>
<tbody>
{res.map((row) => (
<tr key={row.first_seen}>
<td className="bg-slate-50 text-sm font-medium text-slate-500">
{row.first_seen}
</td>
<Cell
value={row.period_0}
ratio={calculateRatio(row.period_0)}
/>
<Cell
value={row.period_1}
ratio={calculateRatio(row.period_1)}
/>
<Cell
value={row.period_2}
ratio={calculateRatio(row.period_2)}
/>
<Cell
value={row.period_3}
ratio={calculateRatio(row.period_3)}
/>
<Cell
value={row.period_4}
ratio={calculateRatio(row.period_4)}
/>
<Cell
value={row.period_5}
ratio={calculateRatio(row.period_5)}
/>
<Cell
value={row.period_6}
ratio={calculateRatio(row.period_6)}
/>
<Cell
value={row.period_7}
ratio={calculateRatio(row.period_7)}
/>
<Cell
value={row.period_8}
ratio={calculateRatio(row.period_8)}
/>
<Cell
value={row.period_9}
ratio={calculateRatio(row.period_9)}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Widget>
);
};
export default withLoadingWidget(WeeklyCohortsServer);

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -23,7 +23,11 @@ export default async function Page({
}
return (
<PageLayout title={organization.name} organizationSlug={organizationSlug}>
<>
<PageLayout
title={organization.name}
organizationSlug={organizationSlug}
/>
<div className="grid gap-8 p-4 lg:grid-cols-2">
<EditOrganization organization={organization} />
<div className="col-span-2">
@@ -33,6 +37,6 @@ export default async function Page({
<InvitesServer organizationSlug={organizationSlug} />
</div>
</div>
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -18,11 +18,15 @@ export default async function Page({
const profile = await getUserById(userId!);
return (
<PageLayout title={profile.lastName} organizationSlug={organizationSlug}>
<>
<PageLayout
title={profile.lastName}
organizationSlug={organizationSlug}
/>
<div className="flex flex-col gap-4 p-4">
<EditProfile profile={profile} />
<Logout />
</div>
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -22,8 +22,9 @@ export default async function Page({
]);
return (
<PageLayout title="Projects" organizationSlug={organizationSlug}>
<>
<PageLayout title="Projects" organizationSlug={organizationSlug} />
<ListProjects projects={projects} clients={clients} />
</PageLayout>
</>
);
}

View File

@@ -0,0 +1,3 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
export default FullPageLoadingState;

View File

@@ -23,8 +23,9 @@ export default async function Page({
});
return (
<PageLayout title="References" organizationSlug={organizationSlug}>
<>
<PageLayout title="References" organizationSlug={organizationSlug} />
<ListReferences data={references} />
</PageLayout>
</>
);
}

View File

@@ -20,7 +20,7 @@ export function Card({ children, hover, className }: CardProps) {
<div
className={cn(
'card relative',
hover && 'transition-all hover:border-black',
hover && 'transition-all hover:-translate-y-0.5',
className
)}
>

View File

@@ -41,7 +41,7 @@ export function ChartSSR({
}
return (
<div className="@container relative h-full w-full">
<div className="relative h-full w-full">
{/* Chart area */}
<svg className="absolute inset-0 h-full w-full overflow-visible">
<svg

View File

@@ -0,0 +1,22 @@
import type { LucideIcon } from 'lucide-react';
import { Loader2Icon } from 'lucide-react';
import { FullPageEmptyState } from './full-page-empty-state';
const FullPageLoadingState = () => {
return (
<FullPageEmptyState
className="min-h-[calc(100vh-theme(spacing.16))]"
title="Fetching..."
icon={
((props) => (
<Loader2Icon {...props} className="animate-spin" />
)) as LucideIcon
}
>
Wait a moment while we fetch your dashboards
</FullPageEmptyState>
);
};
export default FullPageLoadingState;

View File

@@ -1,11 +1,13 @@
import withSuspense from '@/hocs/with-suspense';
import { getLiveVisitors } from '@openpanel/db';
import type { LiveCounterProps } from './live-counter';
import LiveCounter from './live-counter';
export default async function ServerLiveCounter(
props: Omit<LiveCounterProps, 'data'>
) {
async function ServerLiveCounter(props: Omit<LiveCounterProps, 'data'>) {
const count = await getLiveVisitors(props.projectId);
return <LiveCounter data={count} {...props} />;
}
export default withSuspense(ServerLiveCounter, () => <div />);

View File

@@ -0,0 +1,20 @@
import { Button } from '@/components/ui/button';
import withSuspense from '@/hocs/with-suspense';
import { Globe2Icon } from 'lucide-react';
import { getShareByProjectId } from '@openpanel/db';
import { OverviewShare } from './overview-share';
type Props = {
projectId: string;
};
const OverviewShareServer = async ({ projectId }: Props) => {
const share = await getShareByProjectId(projectId);
return <OverviewShare data={share} />;
};
export default withSuspense(OverviewShareServer, () => (
<Button icon={Globe2Icon}>Private</Button>
));

View File

@@ -1,5 +1,13 @@
'use client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { pushModal } from '@/modals';
import { api } from '@/trpc/client';
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
@@ -8,15 +16,6 @@ import { useRouter } from 'next/navigation';
import type { ShareOverview } from '@openpanel/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
interface OverviewShareProps {
data: ShareOverview | null;
}

View File

@@ -9,6 +9,7 @@ import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
Legend,
Line,
LineChart,
ReferenceLine,
@@ -79,6 +80,7 @@ export function ReportLineChart({
allowDecimals={false}
tickFormatter={number.short}
/>
<Legend wrapperStyle={{ fontSize: '10px' }} />
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}

View File

@@ -10,6 +10,25 @@ interface Props<T> {
className?: string;
}
export const WidgetTableHead = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<thead
className={cn(
'sticky top-0 z-10 border-b border-border bg-slate-50 text-sm text-slate-500 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium',
className
)}
>
{children}
</thead>
);
};
export function WidgetTable<T>({
className,
columns,
@@ -18,13 +37,13 @@ export function WidgetTable<T>({
}: Props<T>) {
return (
<table className={cn('w-full', className)}>
<thead className="sticky top-0 z-50 border-b border-border bg-slate-50 text-sm text-slate-500 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium">
<WidgetTableHead>
<tr>
{columns.map((column) => (
<th key={column.name}>{column.name}</th>
))}
</tr>
</thead>
</WidgetTableHead>
<tbody>
{data.map((item) => (
<tr

View File

@@ -0,0 +1,34 @@
import { Suspense } from 'react';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { Widget, WidgetHead } from '@/components/widget';
type Props = Record<string, unknown> & {
className?: string;
};
const withLoadingWidget = <P extends Props>(
Component: React.ComponentType<P>
) => {
const WithLoadingWidget: React.ComponentType<P> = (props) => {
return (
<Suspense
fallback={
<Widget className={props.className}>
<WidgetHead>
<span className="title">Loading...</span>
</WidgetHead>
<ChartLoading />
</Widget>
}
>
<Component {...(props as any)} />
</Suspense>
);
};
WithLoadingWidget.displayName = `WithLoadingWidget(${Component.displayName})`;
return WithLoadingWidget;
};
export default withLoadingWidget;

View File

@@ -0,0 +1,21 @@
import { Suspense } from 'react';
const withSuspense = <P,>(
Component: React.ComponentType<P>,
Fallback: React.ComponentType<P>
) => {
const WithSuspense: React.ComponentType<P> = (props) => {
const fallback = <Fallback {...(props as any)} key="faaaaalling" />;
return (
<Suspense fallback={fallback}>
<Component {...(props as any)} />
</Suspense>
);
};
WithSuspense.displayName = `WithSuspense(${Component.displayName})`;
return WithSuspense;
};
export default withSuspense;

View File

@@ -1,3 +1,4 @@
import { isSameYear } from 'date-fns';
import type { FormatStyleName } from 'javascript-time-ago';
import TimeAgo from 'javascript-time-ago';
import en from 'javascript-time-ago/locale/en';
@@ -16,7 +17,16 @@ export function getLocale() {
}
export function formatDate(date: Date) {
return new Intl.DateTimeFormat(getLocale()).format(date);
const options: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'numeric',
};
if (!isSameYear(date, new Date())) {
options.year = 'numeric';
}
return new Intl.DateTimeFormat(getLocale(), options).format(date);
}
export function formatDateTime(date: Date) {

View File

@@ -74,6 +74,20 @@ ORDER BY
(a, b) SETTINGS index_granularity = 8192;
ALTER TABLE
test.events_bots
events_bots
ADD
COLUMN id UUID DEFAULT generateUUIDv4() FIRST;
COLUMN id UUID DEFAULT generateUUIDv4() FIRST;
--- Materialized views (DAU)
CREATE MATERIALIZED VIEW dau_mv ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMMDD(date)
ORDER BY
(project_id, date) POPULATE AS
SELECT
toDate(created_at) as date,
uniqState(profile_id) as profile_id,
project_id
FROM
events
GROUP BY
date,
project_id;

View File

@@ -14,3 +14,4 @@ export * from './src/services/share.service';
export * from './src/services/user.service';
export * from './src/services/reference.service';
export * from './src/services/id.service';
export * from './src/services/retention.service';

View File

@@ -5,6 +5,7 @@ export type IServiceDashboard = Dashboard;
export type IServiceDashboards = Prisma.DashboardGetPayload<{
include: {
project: true;
reports: true;
};
}>[];
@@ -33,6 +34,7 @@ export function getDashboardsByProjectId(projectId: string) {
},
include: {
project: true,
reports: true,
},
});
}

View File

@@ -0,0 +1,158 @@
import { escape } from 'sqlstring';
import { chQuery } from '../clickhouse-client';
type IGetWeekRetentionInput = {
projectId: string;
};
// https://www.geeksforgeeks.org/how-to-calculate-retention-rate-in-sql/
export function getRetentionCohortTable({ projectId }: IGetWeekRetentionInput) {
const sql = `
WITH
m AS
(
SELECT
profile_id,
max(toWeek(created_at)) AS last_seen
FROM events
WHERE (project_id = ${escape(projectId)}) AND (profile_id != device_id)
GROUP BY profile_id
),
n AS
(
SELECT
profile_id,
min(toWeek(created_at)) AS first_seen
FROM events
WHERE (project_id = ${escape(projectId)}) AND (profile_id != device_id)
GROUP BY profile_id
),
a AS
(
SELECT
m.profile_id,
m.last_seen,
n.first_seen,
m.last_seen - n.first_seen AS diff
FROM m, n
WHERE m.profile_id = n.profile_id
)
SELECT
first_seen,
SUM(multiIf(diff = 0, 1, 0)) AS period_0,
SUM(multiIf(diff = 1, 1, 0)) AS period_1,
SUM(multiIf(diff = 2, 1, 0)) AS period_2,
SUM(multiIf(diff = 3, 1, 0)) AS period_3,
SUM(multiIf(diff = 4, 1, 0)) AS period_4,
SUM(multiIf(diff = 5, 1, 0)) AS period_5,
SUM(multiIf(diff = 6, 1, 0)) AS period_6,
SUM(multiIf(diff = 7, 1, 0)) AS period_7,
SUM(multiIf(diff = 8, 1, 0)) AS period_8,
SUM(multiIf(diff = 9, 1, 0)) AS period_9
FROM a
GROUP BY first_seen
ORDER BY first_seen ASC
`;
return chQuery<{
first_seen: number;
period_0: number;
period_1: number;
period_2: number;
period_3: number;
period_4: number;
period_5: number;
period_6: number;
period_7: number;
period_8: number;
period_9: number;
}>(sql);
}
// Retention graph
// https://www.sisense.com/blog/how-to-calculate-cohort-retention-in-sql/
export function getRetentionSeries({ projectId }: IGetWeekRetentionInput) {
const sql = `
SELECT
toStartOfWeek(events.created_at) AS date,
countDistinct(events.profile_id) AS active_users,
countDistinct(future_events.profile_id) AS retained_users,
(100 * (countDistinct(future_events.profile_id) / CAST(countDistinct(events.profile_id), 'float'))) AS retention
FROM events
LEFT JOIN events AS future_events ON
events.profile_id = future_events.profile_id
AND toStartOfWeek(events.created_at) = toStartOfWeek(future_events.created_at - toIntervalWeek(1))
AND future_events.profile_id != future_events.device_id
WHERE
project_id = ${escape(projectId)}
AND events.profile_id != events.device_id
GROUP BY 1
ORDER BY date ASC`;
return chQuery<{
date: string;
active_users: number;
retained_users: number;
retention: number;
}>(sql);
}
// https://medium.com/@andre_bodro/how-to-fast-calculating-mau-in-clickhouse-fd793559b229
// Rolling active users
export type IServiceRetentionRollingActiveUsers = {
date: string;
users: number;
};
export function getRollingActiveUsers({
projectId,
days,
}: IGetWeekRetentionInput & { days: number }) {
const sql = `
SELECT
date,
uniqMerge(profile_id) AS users
FROM
(
SELECT
date + n AS date,
profile_id,
project_id
FROM
(
SELECT *
FROM dau_mv
WHERE project_id = ${escape(projectId)}
)
ARRAY JOIN range(${days}) AS n
)
WHERE project_id = ${escape(projectId)}
GROUP BY date`;
return chQuery<IServiceRetentionRollingActiveUsers>(sql);
}
export function getRetentionLastSeenSeries({
projectId,
}: IGetWeekRetentionInput) {
const sql = `
WITH last_active AS (
SELECT
max(created_at) AS last_active,
profile_id
FROM events
WHERE (project_id = ${escape(projectId)}) AND (device_id != profile_id)
GROUP BY profile_id
)
SELECT
dateDiff('day', last_active, today()) AS days,
countDistinct(profile_id) AS users
FROM last_active
GROUP BY days
ORDER BY days ASC`;
return chQuery<{
days: number;
users: number;
}>(sql);
}