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
|
||||||
dump-*
|
dump-*
|
||||||
.sql
|
.sql
|
||||||
clickhouse
|
./clickhouse
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,9 @@ export default async function Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={dashboard.name} organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title={dashboard.name} organizationSlug={organizationSlug} />
|
||||||
<ListReports reports={reports} />
|
<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 { pushModal } from '@/modals';
|
||||||
import { PlusIcon } from 'lucide-react';
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { StickyBelowHeader } from '../layout-sticky-below-header';
|
import { StickyBelowHeader } from '../../layout-sticky-below-header';
|
||||||
|
|
||||||
export function HeaderDashboards() {
|
export function HeaderDashboards() {
|
||||||
return (
|
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 PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
|
||||||
|
|
||||||
import { getDashboardsByProjectId } from '@openpanel/db';
|
import ListDashboardsServer from './list-dashboards';
|
||||||
|
|
||||||
import { HeaderDashboards } from './header-dashboards';
|
|
||||||
import { ListDashboards } from './list-dashboards';
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: {
|
params: {
|
||||||
@@ -12,15 +9,13 @@ interface PageProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Page({
|
export default function Page({
|
||||||
params: { projectId, organizationSlug },
|
params: { projectId, organizationSlug },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
const dashboards = await getDashboardsByProjectId(projectId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Dashboards" organizationSlug={organizationSlug}>
|
<>
|
||||||
{dashboards.length > 0 && <HeaderDashboards />}
|
<PageLayout title="Dashboards" organizationSlug={organizationSlug} />
|
||||||
<ListDashboards dashboards={dashboards} />
|
<ListDashboardsServer projectId={projectId} />
|
||||||
</PageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<PageLayout title="Events" organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title="Events" organizationSlug={organizationSlug} />
|
||||||
<StickyBelowHeader className="flex justify-between p-4">
|
<StickyBelowHeader className="flex justify-between p-4">
|
||||||
<OverviewFiltersDrawer
|
<OverviewFiltersDrawer
|
||||||
mode="events"
|
mode="events"
|
||||||
@@ -82,6 +83,6 @@ export default async function Page({
|
|||||||
<EventConversionsListServer projectId={projectId} />
|
<EventConversionsListServer projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
KeySquareIcon,
|
KeySquareIcon,
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
UserSearchIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
WallpaperIcon,
|
WallpaperIcon,
|
||||||
WarehouseIcon,
|
WarehouseIcon,
|
||||||
@@ -96,6 +97,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
label="Events"
|
label="Events"
|
||||||
href={`/${params.organizationSlug}/${projectId}/events`}
|
href={`/${params.organizationSlug}/${projectId}/events`}
|
||||||
/>
|
/>
|
||||||
|
<LinkWithIcon
|
||||||
|
icon={UserSearchIcon}
|
||||||
|
label="Retention"
|
||||||
|
href={`/${params.organizationSlug}/${projectId}/retention`}
|
||||||
|
/>
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={UsersIcon}
|
icon={UsersIcon}
|
||||||
label="Profiles"
|
label="Profiles"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function LayoutSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setActive(false)}
|
onClick={() => setActive(false)}
|
||||||
className={cn(
|
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
|
active
|
||||||
? 'pointer-events-auto opacity-100'
|
? 'pointer-events-auto opacity-100'
|
||||||
: 'pointer-events-none opacity-0'
|
: 'pointer-events-none opacity-0'
|
||||||
@@ -46,7 +46,7 @@ export function LayoutSidebar({
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
'-translate-x-72 lg:-translate-x-0', // responsive
|
||||||
active && 'translate-x-0' // force active on mobile
|
active && 'translate-x-0' // force active on mobile
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import DarkModeToggle from '@/components/dark-mode-toggle';
|
import DarkModeToggle from '@/components/dark-mode-toggle';
|
||||||
|
import withSuspense from '@/hocs/with-suspense';
|
||||||
|
|
||||||
import {
|
import { getCurrentProjects } from '@openpanel/db';
|
||||||
getCurrentProjects,
|
|
||||||
getProjectsByOrganizationSlug,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
|
|
||||||
import LayoutProjectSelector from './layout-project-selector';
|
import LayoutProjectSelector from './layout-project-selector';
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
organizationSlug: string;
|
organizationSlug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function PageLayout({
|
async function PageLayout({ title, organizationSlug }: PageLayoutProps) {
|
||||||
children,
|
|
||||||
title,
|
|
||||||
organizationSlug,
|
|
||||||
}: PageLayoutProps) {
|
|
||||||
const projects = await getCurrentProjects(organizationSlug);
|
const projects = await getCurrentProjects(organizationSlug);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,7 +24,16 @@ export default async function PageLayout({
|
|||||||
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
{projects.length > 0 && <LayoutProjectSelector projects={projects} />}
|
||||||
</div>
|
</div>
|
||||||
</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 { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
import ServerLiveCounter from '@/components/overview/live-counter';
|
import ServerLiveCounter from '@/components/overview/live-counter';
|
||||||
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
|
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 OverviewTopDevices from '@/components/overview/overview-top-devices';
|
||||||
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
import OverviewTopEvents from '@/components/overview/overview-top-events';
|
||||||
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
import OverviewTopGeo from '@/components/overview/overview-top-geo';
|
||||||
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
import OverviewTopPages from '@/components/overview/overview-top-pages';
|
||||||
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
import OverviewTopSources from '@/components/overview/overview-top-sources';
|
||||||
|
|
||||||
import { getShareByProjectId } from '@openpanel/db';
|
|
||||||
|
|
||||||
import OverviewMetrics from '../../../../components/overview/overview-metrics';
|
import OverviewMetrics from '../../../../components/overview/overview-metrics';
|
||||||
import { StickyBelowHeader } from './layout-sticky-below-header';
|
import { StickyBelowHeader } from './layout-sticky-below-header';
|
||||||
import { OverviewReportRange } from './overview-sticky-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 },
|
params: { organizationSlug, projectId },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
const share = await getShareByProjectId(projectId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Overview" organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title="Overview" organizationSlug={organizationSlug} />
|
||||||
<StickyBelowHeader>
|
<StickyBelowHeader>
|
||||||
<div className="flex justify-between gap-2 p-4">
|
<div className="flex justify-between gap-2 p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -38,12 +35,12 @@ export default async function Page({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ServerLiveCounter projectId={projectId} />
|
<ServerLiveCounter projectId={projectId} />
|
||||||
<OverviewShare data={share} />
|
<OverviewShareServer projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OverviewFiltersButtons />
|
<OverviewFiltersButtons />
|
||||||
</StickyBelowHeader>
|
</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">
|
<div className="col-span-6">
|
||||||
<OverviewLiveHistogram projectId={projectId} />
|
<OverviewLiveHistogram projectId={projectId} />
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +50,7 @@ export default async function Page({
|
|||||||
<OverviewTopDevices projectId={projectId} />
|
<OverviewTopDevices projectId={projectId} />
|
||||||
<OverviewTopEvents projectId={projectId} />
|
<OverviewTopEvents projectId={projectId} />
|
||||||
<OverviewTopGeo projectId={projectId} />
|
<OverviewTopGeo projectId={projectId} />
|
||||||
</div>
|
</div> */}
|
||||||
</PageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
return (
|
||||||
<PageLayout
|
<>
|
||||||
organizationSlug={organizationSlug}
|
<PageLayout
|
||||||
title={
|
organizationSlug={organizationSlug}
|
||||||
<div className="flex items-center gap-2">
|
title={
|
||||||
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
<div className="flex items-center gap-2">
|
||||||
{getProfileName(profile)}
|
<ProfileAvatar {...profile} size="sm" className="hidden sm:block" />
|
||||||
</div>
|
{getProfileName(profile)}
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
|
/>
|
||||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
{/* <StickyBelowHeader className="flex justify-between p-4">
|
||||||
<OverviewFiltersDrawer
|
<OverviewFiltersDrawer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -189,7 +190,7 @@ export default async function Page({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 },
|
searchParams: { cursor, f },
|
||||||
}: PageProps) {
|
}: PageProps) {
|
||||||
return (
|
return (
|
||||||
<PageLayout title="Profiles" organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title="Profiles" organizationSlug={organizationSlug} />
|
||||||
{/* <StickyBelowHeader className="flex justify-between p-4">
|
{/* <StickyBelowHeader className="flex justify-between p-4">
|
||||||
<OverviewFiltersDrawer
|
<OverviewFiltersDrawer
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -57,6 +58,6 @@ export default function Page({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,17 @@ export default async function Page({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<>
|
||||||
organizationSlug={organizationSlug}
|
<PageLayout
|
||||||
title={
|
organizationSlug={organizationSlug}
|
||||||
<div className="flex cursor-pointer items-center gap-2">
|
title={
|
||||||
{report.name}
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
<Pencil size={16} />
|
{report.name}
|
||||||
</div>
|
<Pencil size={16} />
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
|
/>
|
||||||
<ReportEditor report={report} />
|
<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) {
|
export default function Page({ params: { organizationSlug } }: PageProps) {
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<>
|
||||||
organizationSlug={organizationSlug}
|
<PageLayout
|
||||||
title={
|
organizationSlug={organizationSlug}
|
||||||
<div className="flex cursor-pointer items-center gap-2">
|
title={
|
||||||
Unnamed report
|
<div className="flex cursor-pointer items-center gap-2">
|
||||||
<Pencil size={16} />
|
Unnamed report
|
||||||
</div>
|
<Pencil size={16} />
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
|
/>
|
||||||
<ReportEditor report={null} />
|
<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 (
|
return (
|
||||||
<PageLayout title={organization.name} organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout
|
||||||
|
title={organization.name}
|
||||||
|
organizationSlug={organizationSlug}
|
||||||
|
/>
|
||||||
<div className="grid gap-8 p-4 lg:grid-cols-2">
|
<div className="grid gap-8 p-4 lg:grid-cols-2">
|
||||||
<EditOrganization organization={organization} />
|
<EditOrganization organization={organization} />
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
@@ -33,6 +37,6 @@ export default async function Page({
|
|||||||
<InvitesServer organizationSlug={organizationSlug} />
|
<InvitesServer organizationSlug={organizationSlug} />
|
||||||
</div>
|
</div>
|
||||||
</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!);
|
const profile = await getUserById(userId!);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={profile.lastName} organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout
|
||||||
|
title={profile.lastName}
|
||||||
|
organizationSlug={organizationSlug}
|
||||||
|
/>
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
<EditProfile profile={profile} />
|
<EditProfile profile={profile} />
|
||||||
<Logout />
|
<Logout />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<PageLayout title="Projects" organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title="Projects" organizationSlug={organizationSlug} />
|
||||||
<ListProjects projects={projects} clients={clients} />
|
<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 (
|
return (
|
||||||
<PageLayout title="References" organizationSlug={organizationSlug}>
|
<>
|
||||||
|
<PageLayout title="References" organizationSlug={organizationSlug} />
|
||||||
<ListReferences data={references} />
|
<ListReferences data={references} />
|
||||||
</PageLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function Card({ children, hover, className }: CardProps) {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'card relative',
|
'card relative',
|
||||||
hover && 'transition-all hover:border-black',
|
hover && 'transition-all hover:-translate-y-0.5',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function ChartSSR({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="@container relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{/* Chart area */}
|
{/* Chart area */}
|
||||||
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
||||||
<svg
|
<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 { getLiveVisitors } from '@openpanel/db';
|
||||||
|
|
||||||
import type { LiveCounterProps } from './live-counter';
|
import type { LiveCounterProps } from './live-counter';
|
||||||
import LiveCounter from './live-counter';
|
import LiveCounter from './live-counter';
|
||||||
|
|
||||||
export default async function ServerLiveCounter(
|
async function ServerLiveCounter(props: Omit<LiveCounterProps, 'data'>) {
|
||||||
props: Omit<LiveCounterProps, 'data'>
|
|
||||||
) {
|
|
||||||
const count = await getLiveVisitors(props.projectId);
|
const count = await getLiveVisitors(props.projectId);
|
||||||
return <LiveCounter data={count} {...props} />;
|
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';
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
|
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
|
||||||
@@ -8,15 +16,6 @@ import { useRouter } from 'next/navigation';
|
|||||||
|
|
||||||
import type { ShareOverview } from '@openpanel/db';
|
import type { ShareOverview } from '@openpanel/db';
|
||||||
|
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../ui/dropdown-menu';
|
|
||||||
|
|
||||||
interface OverviewShareProps {
|
interface OverviewShareProps {
|
||||||
data: ShareOverview | null;
|
data: ShareOverview | null;
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ import type { IChartData } from '@/trpc/client';
|
|||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
@@ -79,6 +80,7 @@ export function ReportLineChart({
|
|||||||
allowDecimals={false}
|
allowDecimals={false}
|
||||||
tickFormatter={number.short}
|
tickFormatter={number.short}
|
||||||
/>
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: '10px' }} />
|
||||||
<Tooltip content={<ReportChartTooltip />} />
|
<Tooltip content={<ReportChartTooltip />} />
|
||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ interface Props<T> {
|
|||||||
className?: string;
|
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>({
|
export function WidgetTable<T>({
|
||||||
className,
|
className,
|
||||||
columns,
|
columns,
|
||||||
@@ -18,13 +37,13 @@ export function WidgetTable<T>({
|
|||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<table className={cn('w-full', className)}>
|
<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>
|
<tr>
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<th key={column.name}>{column.name}</th>
|
<th key={column.name}>{column.name}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</WidgetTableHead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((item) => (
|
{data.map((item) => (
|
||||||
<tr
|
<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 type { FormatStyleName } from 'javascript-time-ago';
|
||||||
import TimeAgo from 'javascript-time-ago';
|
import TimeAgo from 'javascript-time-ago';
|
||||||
import en from 'javascript-time-ago/locale/en';
|
import en from 'javascript-time-ago/locale/en';
|
||||||
@@ -16,7 +17,16 @@ export function getLocale() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date) {
|
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) {
|
export function formatDateTime(date: Date) {
|
||||||
|
|||||||
@@ -74,6 +74,20 @@ ORDER BY
|
|||||||
(a, b) SETTINGS index_granularity = 8192;
|
(a, b) SETTINGS index_granularity = 8192;
|
||||||
|
|
||||||
ALTER TABLE
|
ALTER TABLE
|
||||||
test.events_bots
|
events_bots
|
||||||
ADD
|
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/user.service';
|
||||||
export * from './src/services/reference.service';
|
export * from './src/services/reference.service';
|
||||||
export * from './src/services/id.service';
|
export * from './src/services/id.service';
|
||||||
|
export * from './src/services/retention.service';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type IServiceDashboard = Dashboard;
|
|||||||
export type IServiceDashboards = Prisma.DashboardGetPayload<{
|
export type IServiceDashboards = Prisma.DashboardGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
project: true;
|
project: true;
|
||||||
|
reports: true;
|
||||||
};
|
};
|
||||||
}>[];
|
}>[];
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export function getDashboardsByProjectId(projectId: string) {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
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