dashboard: add retention and quick fix loading states
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,7 +4,7 @@ packages/sdk/test.ts
|
||||
dump.sql
|
||||
dump-*
|
||||
.sql
|
||||
clickhouse
|
||||
./clickhouse
|
||||
|
||||
# Logs
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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'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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||
|
||||
export default FullPageLoadingState;
|
||||
@@ -23,8 +23,9 @@ export default async function Page({
|
||||
});
|
||||
|
||||
return (
|
||||
<PageLayout title="References" organizationSlug={organizationSlug}>
|
||||
<>
|
||||
<PageLayout title="References" organizationSlug={organizationSlug} />
|
||||
<ListReferences data={references} />
|
||||
</PageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
22
apps/dashboard/src/components/full-page-loading-state.tsx
Normal file
22
apps/dashboard/src/components/full-page-loading-state.tsx
Normal 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;
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
apps/dashboard/src/hocs/with-loading-widget.tsx
Normal file
34
apps/dashboard/src/hocs/with-loading-widget.tsx
Normal 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;
|
||||
21
apps/dashboard/src/hocs/with-suspense.tsx
Normal file
21
apps/dashboard/src/hocs/with-suspense.tsx
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
158
packages/db/src/services/retention.service.ts
Normal file
158
packages/db/src/services/retention.service.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user