diff --git a/.gitignore b/.gitignore index a70ee3fd..d28ee8fa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ packages/sdk/test.ts dump.sql dump-* .sql -clickhouse +./clickhouse # Logs diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/page.tsx index 86b3cb5a..6cb18c14 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/[dashboardId]/page.tsx @@ -26,8 +26,9 @@ export default async function Page({ } return ( - + <> + - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards.tsx deleted file mode 100644 index a12facad..00000000 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards.tsx +++ /dev/null @@ -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 ( - -

You have not created any dashboards for this project yet

- -
- ); - } - - return ( - <> -
- {dashboards.map((item) => ( - -
- - {item.name} - -
- - - - - - - - - -
- ))} -
- - ); -} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/header-dashboards.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/header.tsx similarity index 89% rename from apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/header-dashboards.tsx rename to apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/header.tsx index 1b5fd45f..e39bf721 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/header-dashboards.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/header.tsx @@ -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 ( diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/index.tsx new file mode 100644 index 00000000..81c78c7c --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/index.tsx @@ -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 && } + + + ); +}; + +export default withSuspense(ListDashboardsServer, FullPageLoadingState); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx new file mode 100644 index 00000000..f116e807 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/list-dashboards/list-dashboards.tsx @@ -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 ( + +

You have not created any dashboards for this project yet

+ +
+ ); + } + + return ( + <> +
+ {dashboards.map((item) => { + const visibleReports = item.reports.slice( + 0, + item.reports.length > 6 ? 5 : 6 + ); + return ( + +
+ +
+
{item.name}
+
+ {item.updatedAt.toLocaleString()} +
+
+
+ {visibleReports.map((report) => { + const Icon = { + bar: BarChartHorizontalIcon, + linear: LineChartIcon, + pie: PieChartIcon, + metric: HashIcon, + map: Globe2Icon, + histogram: BarChart3Icon, + funnel: ConeIcon, + area: AreaChartIcon, + }[report.chartType]; + + return ( +
+ +
+ {report.name} +
+
+ ); + })} + {item.reports.length > 6 && ( +
+ +
+ {item.reports.length - 5} more +
+
+ )} +
+ {/* + + {item.reports.length} reports + + {item.reports.map((item) => item.name).join(', ')} + */} + +
+ + + + + + + + + +
+ ); + })} +
+ + ); +} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/page.tsx index 5058afb1..689ea239 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/dashboards/page.tsx @@ -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 ( - - {dashboards.length > 0 && } - - + <> + + + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/page.tsx index d3e57f5b..b251a43e 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/page.tsx @@ -56,7 +56,8 @@ export default async function Page({ ]); return ( - + <> + - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx index 393a5dd6..89aef5cb 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx @@ -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`} /> + 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({ />
0 && }
-
{children}
); } + +const Loading = ({ title }: PageLayoutProps) => ( + <> +
+
{title}
+
+ +); + +export default withSuspense(PageLayout, Loading); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/page.tsx index 55f2b36c..b2aa42d3 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/page.tsx @@ -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 ( - + <> +
@@ -38,12 +35,12 @@ export default async function Page({
- +
-
+ {/*
@@ -53,7 +50,7 @@ export default async function Page({ -
- +
*/} + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx index 503876f4..d11218dc 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/[profileId]/page.tsx @@ -124,15 +124,16 @@ export default async function Page({ } return ( - - - {getProfileName(profile)} - - } - > + <> + + + {getProfileName(profile)} + + } + /> {/* - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx index e3c71115..6f8e8564 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/profiles/page.tsx @@ -29,7 +29,8 @@ export default function Page({ searchParams: { cursor, f }, }: PageProps) { return ( - + <> + {/* - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/[reportId]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/[reportId]/page.tsx index 394870bb..ae5cbc46 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/[reportId]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/[reportId]/page.tsx @@ -24,16 +24,17 @@ export default async function Page({ } return ( - - {report.name} - - - } - > + <> + + {report.name} + + + } + /> - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/page.tsx index 537909b2..6dbe9d5f 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/page.tsx @@ -12,16 +12,17 @@ interface PageProps { export default function Page({ params: { organizationSlug } }: PageProps) { return ( - - Unnamed report - - - } - > + <> + + Unnamed report + + + } + /> - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx new file mode 100644 index 00000000..38b85b95 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/chart.tsx @@ -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 ( +
+
+
+ Days since last seen +
+
{payload.days}
+
+
+
Active users
+
{payload.users}
+
+
+ ); +} + +const Chart = ({ data }: Props) => { + const max = Math.max(...data.map((d) => d.users)); + const number = useNumber(); + return ( +
+ + {({ width, height }) => ( + + + + + + + + + } /> + + + + + + )} + +
+ ); +}; + +export default Chart; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/index.tsx new file mode 100644 index 00000000..b654d370 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/last-active-users/index.tsx @@ -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 ( + + + Last time in days a user was active + + + + ); +}; + +export default withLoadingWidget(LastActiveUsersServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/page.tsx new file mode 100644 index 00000000..4c085ebb --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/page.tsx @@ -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 ( + <> + +
+ + + Experimental feature + +

+ This page is an experimental feature and we'll be working + hard to make it even better. Stay tuned! +

+

+ Please DM me on{' '} + + Discord + {' '} + or{' '} + + X/Twitter + {' '} + if you notice any issues. +

+
+
+ + + + Retention info + + This information is only relevant if you supply a user ID to the + SDK! + + + + + +
+ + ); +}; + +export default Retention; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx new file mode 100644 index 00000000..55c1fe46 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/chart.tsx @@ -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 ( +
+
{payload.date}
+
+
+ Monthly active users +
+
{payload.mau}
+
+
+
Weekly active users
+
{payload.wau}
+
+
+
Daily active users
+
{payload.dau}
+
+
+ ); +} + +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 ( +
+ + {({ width, height }) => ( + + + + + + + + + + + + + + + + + } /> + + + + + + + + )} + +
+ ); +}; + +export default Chart; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/index.tsx new file mode 100644 index 00000000..f0e1db08 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/rolling-active-users/index.tsx @@ -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 ( + + + Rolling active users + + + + ); +}; + +export default withLoadingWidget(RollingActiveUsersServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx new file mode 100644 index 00000000..f4bd5a80 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/chart.tsx @@ -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 ( +
+
+
{formatDate(new Date(date))}
+
+
+
Active Users
+
{active_users}
+
+
+
Retained Users
+
{retained_users}
+
+
+
Retention
+
{round(retention, 2)}%
+
+
+ ); +} + +const Chart = ({ data }: Props) => { + const max = Math.max(...data.map((d) => d.retention)); + const number = useNumber(); + console.log('data', data); + + return ( +
+ + {({ width, height }) => ( + + + + + + + + + } /> + + + formatDate(new Date(m))} + tickLine={false} + allowDuplicatedCategory={false} + label={{ + value: 'DATE', + position: 'insideBottom', + offset: 0, + fontSize: 10, + }} + /> + + + )} + +
+ ); +}; + +export default Chart; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/index.tsx new file mode 100644 index 00000000..9a87469e --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/users-retention-series/index.tsx @@ -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 ( + + + Stickyness / Retention (%) + + + + ); +}; + +export default withLoadingWidget(UsersRetentionSeries); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/weekly-cohorts/index.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/weekly-cohorts/index.tsx new file mode 100644 index 00000000..58c6e3fc --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/retention/weekly-cohorts/index.tsx @@ -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 ( + +
+
{value}
+ + ); +}; + +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 ( + + + Weekly Cohorts + +
+
+ + + + + + + + + + + + + + + + + + {res.map((row) => ( + + + + + + + + + + + + + + ))} + +
Week0123456789
+ {row.first_seen} +
+
+
+
+ ); +}; + +export default withLoadingWidget(WeeklyCohortsServer); diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx index fdb4ef20..4eab4842 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx @@ -23,7 +23,11 @@ export default async function Page({ } return ( - + <> +
@@ -33,6 +37,6 @@ export default async function Page({
-
+ ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx index c2258865..a4733704 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/profile/page.tsx @@ -18,11 +18,15 @@ export default async function Page({ const profile = await getUserById(userId!); return ( - + <> +
-
+ ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx index 5f7c02d5..c968f620 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/projects/page.tsx @@ -22,8 +22,9 @@ export default async function Page({ ]); return ( - + <> + - + ); } diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/loading.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/loading.tsx new file mode 100644 index 00000000..9cf2cf50 --- /dev/null +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/loading.tsx @@ -0,0 +1,3 @@ +import FullPageLoadingState from '@/components/full-page-loading-state'; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/page.tsx index f008741d..cd2593a0 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/references/page.tsx @@ -23,8 +23,9 @@ export default async function Page({ }); return ( - + <> + - + ); } diff --git a/apps/dashboard/src/components/card.tsx b/apps/dashboard/src/components/card.tsx index 46c3870e..1ca0ffd4 100644 --- a/apps/dashboard/src/components/card.tsx +++ b/apps/dashboard/src/components/card.tsx @@ -20,7 +20,7 @@ export function Card({ children, hover, className }: CardProps) {
diff --git a/apps/dashboard/src/components/chart-ssr.tsx b/apps/dashboard/src/components/chart-ssr.tsx index 0ca70366..bf468af5 100644 --- a/apps/dashboard/src/components/chart-ssr.tsx +++ b/apps/dashboard/src/components/chart-ssr.tsx @@ -41,7 +41,7 @@ export function ChartSSR({ } return ( -
+
{/* Chart area */} { + return ( + ( + + )) as LucideIcon + } + > + Wait a moment while we fetch your dashboards + + ); +}; + +export default FullPageLoadingState; diff --git a/apps/dashboard/src/components/overview/live-counter/index.tsx b/apps/dashboard/src/components/overview/live-counter/index.tsx index 2eddb6af..72d43b64 100644 --- a/apps/dashboard/src/components/overview/live-counter/index.tsx +++ b/apps/dashboard/src/components/overview/live-counter/index.tsx @@ -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 -) { +async function ServerLiveCounter(props: Omit) { const count = await getLiveVisitors(props.projectId); return ; } + +export default withSuspense(ServerLiveCounter, () =>
); diff --git a/apps/dashboard/src/components/overview/overview-share/index.tsx b/apps/dashboard/src/components/overview/overview-share/index.tsx new file mode 100644 index 00000000..f37079b4 --- /dev/null +++ b/apps/dashboard/src/components/overview/overview-share/index.tsx @@ -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 ; +}; + +export default withSuspense(OverviewShareServer, () => ( + +)); diff --git a/apps/dashboard/src/components/overview/overview-share.tsx b/apps/dashboard/src/components/overview/overview-share/overview-share.tsx similarity index 95% rename from apps/dashboard/src/components/overview/overview-share.tsx rename to apps/dashboard/src/components/overview/overview-share/overview-share.tsx index 60610907..eabc9221 100644 --- a/apps/dashboard/src/components/overview/overview-share.tsx +++ b/apps/dashboard/src/components/overview/overview-share/overview-share.tsx @@ -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; } diff --git a/apps/dashboard/src/components/report/chart/ReportLineChart.tsx b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx index 28ef0dc5..95f9a229 100644 --- a/apps/dashboard/src/components/report/chart/ReportLineChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportLineChart.tsx @@ -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} /> + } /> { className?: string; } +export const WidgetTableHead = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( + + {children} + + ); +}; + export function WidgetTable({ className, columns, @@ -18,13 +37,13 @@ export function WidgetTable({ }: Props) { return ( - + {columns.map((column) => ( ))} - + {data.map((item) => ( & { + className?: string; +}; + +const withLoadingWidget =

( + Component: React.ComponentType

+) => { + const WithLoadingWidget: React.ComponentType

= (props) => { + return ( + + + Loading... + + + + } + > + + + ); + }; + + WithLoadingWidget.displayName = `WithLoadingWidget(${Component.displayName})`; + + return WithLoadingWidget; +}; + +export default withLoadingWidget; diff --git a/apps/dashboard/src/hocs/with-suspense.tsx b/apps/dashboard/src/hocs/with-suspense.tsx new file mode 100644 index 00000000..7ba914a4 --- /dev/null +++ b/apps/dashboard/src/hocs/with-suspense.tsx @@ -0,0 +1,21 @@ +import { Suspense } from 'react'; + +const withSuspense = ( + Component: React.ComponentType

, + Fallback: React.ComponentType

+) => { + const WithSuspense: React.ComponentType

= (props) => { + const fallback = ; + return ( + + + + ); + }; + + WithSuspense.displayName = `WithSuspense(${Component.displayName})`; + + return WithSuspense; +}; + +export default withSuspense; diff --git a/apps/dashboard/src/utils/date.ts b/apps/dashboard/src/utils/date.ts index 8d7c5e47..d784672f 100644 --- a/apps/dashboard/src/utils/date.ts +++ b/apps/dashboard/src/utils/date.ts @@ -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) { diff --git a/packages/db/clickhouse_tables.sql b/packages/db/clickhouse_tables.sql index 3ee0a8b3..7300bb8e 100644 --- a/packages/db/clickhouse_tables.sql +++ b/packages/db/clickhouse_tables.sql @@ -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; \ No newline at end of file + 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; \ No newline at end of file diff --git a/packages/db/index.ts b/packages/db/index.ts index 6f29807c..137fdb6d 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -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'; diff --git a/packages/db/src/services/dashboard.service.ts b/packages/db/src/services/dashboard.service.ts index 58afcf9e..1cc070c4 100644 --- a/packages/db/src/services/dashboard.service.ts +++ b/packages/db/src/services/dashboard.service.ts @@ -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, }, }); } diff --git a/packages/db/src/services/retention.service.ts b/packages/db/src/services/retention.service.ts new file mode 100644 index 00000000..e2bc195d --- /dev/null +++ b/packages/db/src/services/retention.service.ts @@ -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(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); +}

{column.name}