& {
+ 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);
+}