@umituz/web-dashboard 3.2.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +202 -14
- package/package.json +21 -29
- package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
- package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
- package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
- package/src/domains/analytics/components/MetricCard.tsx +42 -64
- package/src/domains/analytics/hooks/index.ts +7 -1
- package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
- package/src/domains/analytics/index.ts +11 -0
- package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
- package/src/domains/analytics/services/PerformanceService.ts +117 -69
- package/src/domains/analytics/types/analytics.ts +20 -6
- package/src/domains/analytics/types/index.ts +3 -0
- package/src/domains/analytics/utils/analytics.test.ts +183 -0
- package/src/domains/analytics/utils/analytics.ts +26 -9
- package/src/domains/analytics/utils/i18nKeys.ts +40 -0
- package/src/domains/analytics/utils/index.ts +2 -0
- package/src/domains/auth/components/AuthLayout.tsx +63 -54
- package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
- package/src/domains/auth/components/LoginForm.tsx +167 -226
- package/src/domains/auth/components/RegisterForm.tsx +202 -272
- package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
- package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
- package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
- package/src/domains/auth/components/shared/index.ts +20 -0
- package/src/domains/auth/hooks/index.ts +1 -0
- package/src/domains/auth/hooks/useAuth.ts +151 -232
- package/src/domains/auth/index.ts +8 -4
- package/src/domains/auth/types/auth.ts +26 -5
- package/src/domains/auth/types/index.ts +1 -0
- package/src/domains/auth/utils/auth.test.ts +182 -0
- package/src/domains/auth/utils/auth.ts +71 -130
- package/src/domains/auth/utils/i18nKeys.ts +127 -0
- package/src/domains/auth/utils/index.ts +7 -1
- package/src/domains/auth/utils/secureToken.ts +33 -0
- package/src/domains/auth/utils/validationKeys.ts +23 -0
- package/src/domains/calendar/hooks/useCalendar.ts +145 -151
- package/src/domains/calendar/index.ts +0 -1
- package/src/domains/calendar/services/CalendarService.ts +122 -179
- package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
- package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
- package/src/domains/calendar/utils/index.ts +33 -30
- package/src/domains/layouts/components/BrandLogo.tsx +12 -9
- package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
- package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
- package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
- package/src/domains/layouts/types/layout.ts +7 -1
- package/src/domains/layouts/types/user.ts +3 -1
- package/src/domains/modules/build.test.tsx +97 -0
- package/src/domains/modules/build.tsx +71 -0
- package/src/domains/modules/index.ts +2 -0
- package/src/domains/modules/types.ts +29 -0
- package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
- package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
- package/src/domains/onboarding/components/PlanStep.tsx +156 -123
- package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
- package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
- package/src/domains/onboarding/components/StepProgress.tsx +62 -0
- package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
- package/src/domains/onboarding/components/index.ts +4 -0
- package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
- package/src/domains/onboarding/index.ts +1 -0
- package/src/domains/onboarding/types/onboarding.ts +57 -17
- package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
- package/src/domains/onboarding/utils/index.ts +2 -0
- package/src/domains/permissions/PermissionsContext.tsx +103 -0
- package/src/domains/permissions/hasPermission.test.ts +50 -0
- package/src/domains/permissions/hasPermission.ts +38 -0
- package/src/domains/permissions/index.ts +19 -0
- package/src/domains/permissions/types.ts +35 -0
- package/src/domains/permissions/utils/i18nKeys.ts +11 -0
- package/src/domains/session/DemoLogin.tsx +50 -0
- package/src/domains/session/OrgSwitcher.tsx +110 -0
- package/src/domains/session/SessionContext.tsx +150 -0
- package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
- package/src/domains/session/index.ts +18 -0
- package/src/domains/session/types.ts +47 -0
- package/src/domains/session/utils/i18nKeys.ts +12 -0
- package/src/domains/settings/components/AuditLogList.tsx +59 -0
- package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
- package/src/domains/settings/components/SettingsLayout.tsx +75 -60
- package/src/domains/settings/components/SettingsSection.tsx +35 -22
- package/src/domains/settings/components/UserManagementTable.tsx +87 -0
- package/src/domains/settings/components/index.ts +10 -0
- package/src/domains/settings/hooks/useSettings.ts +77 -52
- package/src/domains/settings/types/settings.ts +2 -0
- package/src/domains/settings/utils/i18nKeys.ts +18 -0
- package/src/domains/settings/utils/settings.ts +11 -4
- package/src/i18n/I18nContext.tsx +33 -0
- package/src/i18n/defaultT.test.ts +43 -0
- package/src/i18n/defaultT.ts +34 -0
- package/src/i18n/index.ts +7 -0
- package/src/i18n/types.ts +13 -0
- package/src/index.ts +1 -7
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Checkbox.tsx +40 -0
- package/src/primitives/Input.tsx +19 -0
- package/src/primitives/Skeleton.tsx +26 -0
- package/src/primitives/cn.ts +15 -0
- package/src/primitives/index.ts +23 -0
- package/src/styles/dashboard.css +3045 -0
- package/src/styles/tokens.contract.test.ts +66 -0
- package/src/styles/tokens.css +124 -0
- package/src/domains/billing/components/BillingLayout.tsx +0 -63
- package/src/domains/billing/components/BillingPage.tsx +0 -49
- package/src/domains/billing/components/BillingPortal.tsx +0 -198
- package/src/domains/billing/components/InvoiceCard.tsx +0 -143
- package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
- package/src/domains/billing/components/PlanComparison.tsx +0 -199
- package/src/domains/billing/components/UsageCard.tsx +0 -103
- package/src/domains/billing/components/index.ts +0 -13
- package/src/domains/billing/constants/billing.ts +0 -69
- package/src/domains/billing/hooks/index.ts +0 -7
- package/src/domains/billing/hooks/useBilling.ts +0 -385
- package/src/domains/billing/index.ts +0 -73
- package/src/domains/billing/types/billing.ts +0 -342
- package/src/domains/billing/types/index.ts +0 -30
- package/src/domains/billing/utils/billing.ts +0 -360
- package/src/domains/billing/utils/index.ts +0 -31
- package/src/domains/billing/utils/polar.ts +0 -63
- package/src/domains/calendar/components/index.ts +0 -7
|
@@ -1,178 +1,183 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useAnalytics
|
|
2
|
+
* useAnalytics
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Pure state + side-effect container. Mock data, commented-out
|
|
5
|
+
* fetch calls, and Math.random()-driven time series are gone.
|
|
6
|
+
* All persistence is delegated to the injected `apiClient`.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import { useState, useCallback, useEffect } from "react";
|
|
9
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
|
8
10
|
import type {
|
|
9
11
|
KPIs,
|
|
10
12
|
TimeSeriesData,
|
|
11
|
-
ChartConfig,
|
|
12
13
|
DateRangeValue,
|
|
13
14
|
AnalyticsExportOptions,
|
|
14
15
|
} from "../types/analytics";
|
|
15
|
-
import { createKPI } from "../utils/analytics";
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
/**
|
|
18
|
+
* API client contract. Replace `loadAnalytics` with a real fetch wrapper
|
|
19
|
+
* and `exportAnalytics` with a server-side exporter.
|
|
20
|
+
*/
|
|
21
|
+
export interface AnalyticsApiClient {
|
|
22
|
+
loadAnalytics: (range: DateRangeValue) => Promise<AnalyticsDataPayload>;
|
|
23
|
+
exportAnalytics: (payload: AnalyticsDataPayload, options: AnalyticsExportOptions) => Promise<Blob>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AnalyticsDataPayload {
|
|
27
|
+
kpis: KPIs;
|
|
28
|
+
timeSeries: TimeSeriesData[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseAnalyticsOptions {
|
|
21
32
|
initialDateRange?: DateRangeValue;
|
|
22
33
|
/** Auto-refresh interval in ms (0 to disable) */
|
|
23
34
|
refreshInterval?: number;
|
|
35
|
+
apiClient: AnalyticsApiClient;
|
|
24
36
|
}
|
|
25
37
|
|
|
26
|
-
interface
|
|
27
|
-
/** KPI metrics */
|
|
38
|
+
export interface UseAnalyticsReturn {
|
|
28
39
|
kpis: KPIs;
|
|
29
|
-
/** Time series data */
|
|
30
40
|
timeSeries: TimeSeriesData[];
|
|
31
|
-
/** Loading state */
|
|
32
41
|
isLoading: boolean;
|
|
33
|
-
/** Error state */
|
|
34
42
|
error: string | null;
|
|
43
|
+
dateRange: DateRangeValue;
|
|
44
|
+
updateDateRange: (range: DateRangeValue) => void;
|
|
45
|
+
refresh: () => Promise<void>;
|
|
46
|
+
exportData: (options: AnalyticsExportOptions) => Promise<void>;
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* Manages analytics data fetching and state
|
|
41
|
-
*
|
|
42
|
-
* @param options - Hook options
|
|
43
|
-
* @returns Analytics data and actions
|
|
44
|
-
*/
|
|
45
|
-
export function useAnalytics(options: UseAnalyticsOptions = {}) {
|
|
46
|
-
const { apiUrl = "/api/analytics", initialDateRange, refreshInterval = 0 } = options;
|
|
49
|
+
const DEFAULT_RANGE_DAYS = 30;
|
|
50
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
47
51
|
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const [data, setData] = useState<AnalyticsData>({
|
|
57
|
-
kpis: {
|
|
58
|
-
downloads: { current: 0, previous: 0, growth: 0 },
|
|
59
|
-
engagement: { current: 0, previous: 0, growth: 0 },
|
|
60
|
-
users: { current: 0, previous: 0, growth: 0 },
|
|
61
|
-
revenue: { current: 0, previous: 0, growth: 0 },
|
|
62
|
-
retention: { current: 0, previous: 0, growth: 0 },
|
|
63
|
-
},
|
|
64
|
-
timeSeries: [],
|
|
65
|
-
isLoading: false,
|
|
66
|
-
error: null,
|
|
67
|
-
});
|
|
52
|
+
const buildDefaultRange = (): DateRangeValue => {
|
|
53
|
+
const today = new Date();
|
|
54
|
+
const start = new Date(Date.now() - DEFAULT_RANGE_DAYS * MS_PER_DAY);
|
|
55
|
+
return {
|
|
56
|
+
from: start.toISOString().split("T")[0] as string,
|
|
57
|
+
to: today.toISOString().split("T")[0] as string,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
61
|
+
const EMPTY_KPIS: KPIs = {
|
|
62
|
+
downloads: { current: 0, previous: 0, growth: 0 },
|
|
63
|
+
engagement: { current: 0, previous: 0, growth: 0 },
|
|
64
|
+
users: { current: 0, previous: 0, growth: 0 },
|
|
65
|
+
revenue: { current: 0, previous: 0, growth: 0 },
|
|
66
|
+
retention: { current: 0, previous: 0, growth: 0 },
|
|
67
|
+
};
|
|
72
68
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// const response = await fetch(`${apiUrl}?from=${dateRange.from}&to=${dateRange.to}`);
|
|
76
|
-
// const result = await response.json();
|
|
77
|
-
|
|
78
|
-
// Mock data for demo
|
|
79
|
-
const mockKPIs: KPIs = {
|
|
80
|
-
downloads: { current: 1250, previous: 1100, growth: 13.6 },
|
|
81
|
-
engagement: { current: 68.5, previous: 65.2, growth: 5.1 },
|
|
82
|
-
users: { current: 4500, previous: 4200, growth: 7.1 },
|
|
83
|
-
revenue: { current: 8950, previous: 7800, growth: 14.7 },
|
|
84
|
-
retention: { current: 72.3, previous: 69.8, growth: 3.6 },
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const mockTimeSeries: TimeSeriesData[] = Array.from({ length: 30 }, (_, i) => {
|
|
88
|
-
const date = new Date();
|
|
89
|
-
date.setDate(date.getDate() - (29 - i));
|
|
90
|
-
return {
|
|
91
|
-
date: date.toISOString().split("T")[0],
|
|
92
|
-
downloads: Math.floor(1000 + Math.random() * 500),
|
|
93
|
-
engagement: 60 + Math.random() * 20,
|
|
94
|
-
revenue: Math.floor(7000 + Math.random() * 3000),
|
|
95
|
-
users: Math.floor(4000 + Math.random() * 1000),
|
|
96
|
-
retention: 65 + Math.random() * 15,
|
|
97
|
-
};
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
setData({
|
|
101
|
-
kpis: mockKPIs,
|
|
102
|
-
timeSeries: mockTimeSeries,
|
|
103
|
-
isLoading: false,
|
|
104
|
-
error: null,
|
|
105
|
-
});
|
|
106
|
-
} catch (err) {
|
|
107
|
-
const errorMessage = err instanceof Error ? err.message : "Failed to fetch analytics";
|
|
108
|
-
setData((prev) => ({
|
|
109
|
-
...prev,
|
|
110
|
-
isLoading: false,
|
|
111
|
-
error: errorMessage,
|
|
112
|
-
}));
|
|
113
|
-
}
|
|
114
|
-
}, [apiUrl, dateRange]);
|
|
69
|
+
const toErrorMessage = (err: unknown, fallback: string): string =>
|
|
70
|
+
err instanceof Error && err.message ? err.message : fallback;
|
|
115
71
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
72
|
+
const toError = (err: unknown, fallback: string): Error =>
|
|
73
|
+
err instanceof Error ? err : new Error(fallback);
|
|
74
|
+
|
|
75
|
+
export function useAnalytics(options: UseAnalyticsOptions): UseAnalyticsReturn {
|
|
76
|
+
const { initialDateRange, refreshInterval = 0, apiClient } = options;
|
|
120
77
|
|
|
121
|
-
|
|
122
|
-
|
|
78
|
+
const [dateRange, setDateRange] = useState<DateRangeValue>(
|
|
79
|
+
initialDateRange ?? buildDefaultRange(),
|
|
80
|
+
);
|
|
81
|
+
const [kpis, setKpis] = useState<KPIs>(EMPTY_KPIS);
|
|
82
|
+
const [timeSeries, setTimeSeries] = useState<TimeSeriesData[]>([]);
|
|
83
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
84
|
+
const [error, setError] = useState<string | null>(null);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Refs track the latest request so an in-flight call can't be
|
|
88
|
+
* overridden by a slower one (e.g. user changes the range while
|
|
89
|
+
* the previous fetch is still resolving).
|
|
90
|
+
*/
|
|
91
|
+
const requestIdRef = useRef(0);
|
|
92
|
+
|
|
93
|
+
const refresh = useCallback(async () => {
|
|
94
|
+
const requestId = ++requestIdRef.current;
|
|
95
|
+
setIsLoading(true);
|
|
96
|
+
setError(null);
|
|
123
97
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
format: options.format,
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
console.log("Exporting analytics:", exportData);
|
|
133
|
-
|
|
134
|
-
// Mock export
|
|
135
|
-
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
|
136
|
-
type: options.format === "json" ? "application/json" : "text/plain",
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
const url = URL.createObjectURL(blob);
|
|
140
|
-
const link = document.createElement("a");
|
|
141
|
-
link.href = url;
|
|
142
|
-
link.download = options.filename || `analytics-${dateRange.from}-${dateRange.to}.${options.format}`;
|
|
143
|
-
document.body.appendChild(link);
|
|
144
|
-
link.click();
|
|
145
|
-
document.body.removeChild(link);
|
|
146
|
-
URL.revokeObjectURL(url);
|
|
98
|
+
const payload = await apiClient.loadAnalytics(dateRange);
|
|
99
|
+
if (requestIdRef.current === requestId) {
|
|
100
|
+
setKpis(payload.kpis);
|
|
101
|
+
setTimeSeries(payload.timeSeries);
|
|
102
|
+
}
|
|
147
103
|
} catch (err) {
|
|
148
|
-
|
|
149
|
-
|
|
104
|
+
if (requestIdRef.current === requestId) {
|
|
105
|
+
setError(toErrorMessage(err, 'Failed to fetch analytics'));
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
if (requestIdRef.current === requestId) {
|
|
109
|
+
setIsLoading(false);
|
|
110
|
+
}
|
|
150
111
|
}
|
|
151
|
-
}, [
|
|
112
|
+
}, [apiClient, dateRange]);
|
|
152
113
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
114
|
+
const updateDateRange = useCallback((range: DateRangeValue) => {
|
|
115
|
+
setDateRange(range);
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
const exportData = useCallback(
|
|
119
|
+
async (exportOptions: AnalyticsExportOptions) => {
|
|
120
|
+
const payload: AnalyticsDataPayload = { kpis, timeSeries };
|
|
121
|
+
try {
|
|
122
|
+
const blob = await apiClient.exportAnalytics(payload, exportOptions);
|
|
123
|
+
|
|
124
|
+
const url = URL.createObjectURL(blob);
|
|
125
|
+
const link = document.createElement("a");
|
|
126
|
+
link.href = url;
|
|
127
|
+
link.download =
|
|
128
|
+
exportOptions.filename ?? `analytics-${dateRange.from}-${dateRange.to}.${exportOptions.format}`;
|
|
129
|
+
document.body.appendChild(link);
|
|
130
|
+
link.click();
|
|
131
|
+
document.body.removeChild(link);
|
|
132
|
+
URL.revokeObjectURL(url);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// Surface through the same error state as fetch failures and re-throw
|
|
135
|
+
// so callers that trigger the export programmatically can react.
|
|
136
|
+
setError(toErrorMessage(err, 'Failed to export analytics'));
|
|
137
|
+
throw toError(err, 'Failed to export analytics');
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
[apiClient, dateRange.from, dateRange.to, kpis, timeSeries],
|
|
141
|
+
);
|
|
157
142
|
|
|
158
|
-
// Fetch on mount and date range change
|
|
159
143
|
useEffect(() => {
|
|
160
|
-
|
|
161
|
-
}, [
|
|
144
|
+
refresh();
|
|
145
|
+
}, [refresh]);
|
|
162
146
|
|
|
163
|
-
// Auto-refresh
|
|
164
147
|
useEffect(() => {
|
|
165
148
|
if (refreshInterval > 0) {
|
|
166
|
-
const
|
|
167
|
-
return () => clearInterval(
|
|
149
|
+
const id = setInterval(refresh, refreshInterval);
|
|
150
|
+
return () => clearInterval(id);
|
|
168
151
|
}
|
|
169
|
-
|
|
152
|
+
return undefined;
|
|
153
|
+
}, [refresh, refreshInterval]);
|
|
154
|
+
|
|
155
|
+
return useMemo(
|
|
156
|
+
() => ({
|
|
157
|
+
kpis,
|
|
158
|
+
timeSeries,
|
|
159
|
+
isLoading,
|
|
160
|
+
error,
|
|
161
|
+
dateRange,
|
|
162
|
+
updateDateRange,
|
|
163
|
+
refresh,
|
|
164
|
+
exportData,
|
|
165
|
+
}),
|
|
166
|
+
[kpis, timeSeries, isLoading, error, dateRange, updateDateRange, refresh, exportData],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
170
169
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Convenience factory: returns a stub client that throws. Useful
|
|
172
|
+
* in Storybook / tests; the consumer must wire a real client
|
|
173
|
+
* before the hook is useful in production.
|
|
174
|
+
*/
|
|
175
|
+
export const createStubAnalyticsApiClient = (
|
|
176
|
+
overrides?: Partial<AnalyticsApiClient>,
|
|
177
|
+
): AnalyticsApiClient => {
|
|
178
|
+
const notConfigured: AnalyticsApiClient = {
|
|
179
|
+
loadAnalytics: () => Promise.reject(new Error('AnalyticsApiClient not configured')),
|
|
180
|
+
exportAnalytics: () => Promise.reject(new Error('AnalyticsApiClient not configured')),
|
|
177
181
|
};
|
|
178
|
-
}
|
|
182
|
+
return { ...notConfigured, ...overrides };
|
|
183
|
+
};
|
|
@@ -15,6 +15,13 @@ export {
|
|
|
15
15
|
// Hooks
|
|
16
16
|
export {
|
|
17
17
|
useAnalytics,
|
|
18
|
+
createStubAnalyticsApiClient,
|
|
19
|
+
} from "./hooks";
|
|
20
|
+
export type {
|
|
21
|
+
AnalyticsApiClient,
|
|
22
|
+
AnalyticsDataPayload,
|
|
23
|
+
UseAnalyticsOptions,
|
|
24
|
+
UseAnalyticsReturn,
|
|
18
25
|
} from "./hooks";
|
|
19
26
|
|
|
20
27
|
// Utils
|
|
@@ -37,6 +44,7 @@ export {
|
|
|
37
44
|
roundTo,
|
|
38
45
|
generateColor,
|
|
39
46
|
generateChartColors,
|
|
47
|
+
ANALYTICS_KEYS,
|
|
40
48
|
} from "./utils";
|
|
41
49
|
|
|
42
50
|
// Types
|
|
@@ -55,6 +63,9 @@ export type {
|
|
|
55
63
|
MetricCardProps,
|
|
56
64
|
AnalyticsCardProps,
|
|
57
65
|
AnalyticsLayoutProps,
|
|
66
|
+
AnalyticsPeriod,
|
|
67
|
+
AnalyticsConfig,
|
|
68
|
+
MetricConfig,
|
|
58
69
|
DateRangePreset,
|
|
59
70
|
DateRangeValue,
|
|
60
71
|
ExportFormat,
|
|
@@ -60,22 +60,49 @@ export interface ActivityItem {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* Singleton service for advanced analytics calculations
|
|
63
|
+
* Numeric metric keys supported by segment aggregation.
|
|
64
|
+
* Constrained to a string union to avoid `any` lookups at runtime.
|
|
66
65
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
type NumericUserMetric =
|
|
67
|
+
| 'session_duration'
|
|
68
|
+
| 'pages_per_session'
|
|
69
|
+
| 'bounce_rate'
|
|
70
|
+
| 'lifetime_value'
|
|
71
|
+
| 'last_login_days';
|
|
72
|
+
|
|
73
|
+
const isUserMetric = (key: string): key is NumericUserMetric =>
|
|
74
|
+
[
|
|
75
|
+
'session_duration',
|
|
76
|
+
'pages_per_session',
|
|
77
|
+
'bounce_rate',
|
|
78
|
+
'lifetime_value',
|
|
79
|
+
'last_login_days',
|
|
80
|
+
].includes(key);
|
|
69
81
|
|
|
70
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Safe numeric accessor for a UserData field.
|
|
84
|
+
* Returns 0 when the field is missing or not a number, instead of forcing a cast.
|
|
85
|
+
*/
|
|
86
|
+
const getNumericMetric = (user: UserData, key: NumericUserMetric): number => {
|
|
87
|
+
const value = user[key];
|
|
88
|
+
return typeof value === 'number' ? value : 0;
|
|
89
|
+
};
|
|
71
90
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Segmentation thresholds (in days since last login).
|
|
93
|
+
*/
|
|
94
|
+
const SEGMENT_THRESHOLDS = {
|
|
95
|
+
activeDays: 7,
|
|
96
|
+
moderateDays: 30,
|
|
97
|
+
} as const;
|
|
78
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Analytics Engine Service
|
|
101
|
+
*
|
|
102
|
+
* Pure-functional service for advanced analytics calculations.
|
|
103
|
+
* Stateless and testable: instantiate with `new AnalyticsEngineService()`.
|
|
104
|
+
*/
|
|
105
|
+
export class AnalyticsEngineService {
|
|
79
106
|
/**
|
|
80
107
|
* Calculate cohort retention analysis
|
|
81
108
|
*
|
|
@@ -103,9 +130,13 @@ export class AnalyticsEngineService {
|
|
|
103
130
|
const retention: number[] = [];
|
|
104
131
|
for (let month = 0; month < 12; month++) {
|
|
105
132
|
const activeUsersCount = users.filter((user) => {
|
|
106
|
-
if (!user.last_activity) return false;
|
|
107
|
-
const signupDate = new Date(user.signup_date
|
|
108
|
-
const targetDate = new Date(
|
|
133
|
+
if (!user.last_activity || !user.signup_date) return false;
|
|
134
|
+
const signupDate = new Date(user.signup_date);
|
|
135
|
+
const targetDate = new Date(
|
|
136
|
+
signupDate.getFullYear(),
|
|
137
|
+
signupDate.getMonth() + month,
|
|
138
|
+
1,
|
|
139
|
+
);
|
|
109
140
|
return new Date(user.last_activity) >= targetDate;
|
|
110
141
|
}).length;
|
|
111
142
|
|
|
@@ -116,7 +147,9 @@ export class AnalyticsEngineService {
|
|
|
116
147
|
cohort,
|
|
117
148
|
size: users.length,
|
|
118
149
|
retention,
|
|
119
|
-
averageRetention: retention.
|
|
150
|
+
averageRetention: retention.length === 0
|
|
151
|
+
? 0
|
|
152
|
+
: retention.reduce((a, b) => a + b, 0) / retention.length,
|
|
120
153
|
});
|
|
121
154
|
});
|
|
122
155
|
|
|
@@ -143,7 +176,7 @@ export class AnalyticsEngineService {
|
|
|
143
176
|
const path = paths.get(pathKey);
|
|
144
177
|
if (path) {
|
|
145
178
|
path.count++;
|
|
146
|
-
path.value += conversion.value
|
|
179
|
+
path.value += conversion.value ?? 0;
|
|
147
180
|
totalConversions++;
|
|
148
181
|
}
|
|
149
182
|
});
|
|
@@ -154,7 +187,9 @@ export class AnalyticsEngineService {
|
|
|
154
187
|
conversions: path.count,
|
|
155
188
|
value: path.value,
|
|
156
189
|
abandonmentRate:
|
|
157
|
-
totalConversions > 0
|
|
190
|
+
totalConversions > 0
|
|
191
|
+
? ((totalConversions - path.count) / totalConversions) * 100
|
|
192
|
+
: 0,
|
|
158
193
|
}))
|
|
159
194
|
.sort((a, b) => b.conversions - a.conversions);
|
|
160
195
|
}
|
|
@@ -169,15 +204,16 @@ export class AnalyticsEngineService {
|
|
|
169
204
|
public calculateFunnel(data: FunnelItem[], steps: string[]): FunnelData {
|
|
170
205
|
let previousCount = data.length;
|
|
171
206
|
const funnelSteps = steps.map((step, index) => {
|
|
172
|
-
const stepCount = data.filter((item) => item[step]).length;
|
|
207
|
+
const stepCount = data.filter((item) => Boolean(item[step])).length;
|
|
173
208
|
const conversionRate =
|
|
174
209
|
index === 0 ? 100 : previousCount > 0 ? (stepCount / previousCount) * 100 : 0;
|
|
175
210
|
previousCount = stepCount;
|
|
176
211
|
|
|
177
|
-
const stepData = data.filter((item) => item[step]);
|
|
212
|
+
const stepData = data.filter((item) => Boolean(item[step]));
|
|
178
213
|
const avgTime =
|
|
179
214
|
stepData.length > 0
|
|
180
|
-
? stepData.reduce((sum, item) => sum + (item.time_spent
|
|
215
|
+
? stepData.reduce((sum, item) => sum + (item.time_spent ?? 0), 0) /
|
|
216
|
+
stepData.length
|
|
181
217
|
: 0;
|
|
182
218
|
|
|
183
219
|
return {
|
|
@@ -193,7 +229,7 @@ export class AnalyticsEngineService {
|
|
|
193
229
|
title: 'Conversion Funnel',
|
|
194
230
|
steps: funnelSteps,
|
|
195
231
|
totalUsers: data.length,
|
|
196
|
-
finalConversion: funnelSteps[funnelSteps.length - 1]?.conversionRate
|
|
232
|
+
finalConversion: funnelSteps[funnelSteps.length - 1]?.conversionRate ?? 0,
|
|
197
233
|
};
|
|
198
234
|
}
|
|
199
235
|
|
|
@@ -201,17 +237,19 @@ export class AnalyticsEngineService {
|
|
|
201
237
|
* Generate activity heatmap
|
|
202
238
|
*
|
|
203
239
|
* @param data - Activity data with timestamps
|
|
204
|
-
* @returns Heatmap data by day and hour
|
|
240
|
+
* @returns Heatmap data by day and hour (Monday-first)
|
|
205
241
|
*/
|
|
206
242
|
public generateActivityHeatmap(data: ActivityItem[]): HeatmapData[] {
|
|
207
243
|
const heatmap: HeatmapData[] = [];
|
|
208
|
-
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
244
|
+
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const;
|
|
209
245
|
|
|
210
246
|
for (let day = 0; day < 7; day++) {
|
|
211
247
|
for (let hour = 0; hour < 24; hour++) {
|
|
212
248
|
const count = data.filter((item) => {
|
|
213
249
|
const d = new Date(item.timestamp);
|
|
214
|
-
|
|
250
|
+
// getDay() is Sunday-based (0=Sun..6=Sat); convert to Monday-first index
|
|
251
|
+
const mondayFirst = (d.getDay() + 6) % 7;
|
|
252
|
+
return mondayFirst === day && d.getHours() === hour;
|
|
215
253
|
}).length;
|
|
216
254
|
|
|
217
255
|
heatmap.push({ day: days[day], hour, value: count });
|
|
@@ -230,39 +268,46 @@ export class AnalyticsEngineService {
|
|
|
230
268
|
const defineSegment = (
|
|
231
269
|
name: string,
|
|
232
270
|
filter: (u: UserData) => boolean,
|
|
233
|
-
chars: string[]
|
|
271
|
+
chars: string[],
|
|
234
272
|
) => {
|
|
235
273
|
const users = data.filter(filter);
|
|
236
|
-
const
|
|
237
|
-
users.length === 0
|
|
238
|
-
|
|
239
|
-
|
|
274
|
+
const safeAvg = (field: string): number => {
|
|
275
|
+
if (users.length === 0) return 0;
|
|
276
|
+
if (!isUserMetric(field)) return 0;
|
|
277
|
+
return users.reduce((sum, user) => sum + getNumericMetric(user, field), 0) /
|
|
278
|
+
users.length;
|
|
279
|
+
};
|
|
240
280
|
|
|
241
281
|
return {
|
|
242
282
|
name,
|
|
243
283
|
count: users.length,
|
|
244
|
-
percentage: (users.length / data.length) * 100,
|
|
284
|
+
percentage: data.length === 0 ? 0 : (users.length / data.length) * 100,
|
|
245
285
|
characteristics: chars,
|
|
246
286
|
behavior: {
|
|
247
|
-
avgSessionDuration:
|
|
248
|
-
pagesPerSession:
|
|
249
|
-
bounceRate:
|
|
287
|
+
avgSessionDuration: safeAvg('session_duration'),
|
|
288
|
+
pagesPerSession: safeAvg('pages_per_session'),
|
|
289
|
+
bounceRate: safeAvg('bounce_rate'),
|
|
250
290
|
},
|
|
251
291
|
};
|
|
252
292
|
};
|
|
253
293
|
|
|
254
294
|
return [
|
|
255
|
-
defineSegment('Active', (u) =>
|
|
256
|
-
|
|
257
|
-
'
|
|
258
|
-
]),
|
|
259
|
-
defineSegment('Moderate', (u) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
295
|
+
defineSegment('Active', (u) => {
|
|
296
|
+
const days = u.last_login_days;
|
|
297
|
+
return typeof days === 'number' && days <= SEGMENT_THRESHOLDS.activeDays;
|
|
298
|
+
}, ['Recent login', 'High engagement']),
|
|
299
|
+
defineSegment('Moderate', (u) => {
|
|
300
|
+
const days = u.last_login_days;
|
|
301
|
+
return (
|
|
302
|
+
typeof days === 'number' &&
|
|
303
|
+
days > SEGMENT_THRESHOLDS.activeDays &&
|
|
304
|
+
days <= SEGMENT_THRESHOLDS.moderateDays
|
|
305
|
+
);
|
|
306
|
+
}, ['Occasional login']),
|
|
307
|
+
defineSegment('Inactive', (u) => {
|
|
308
|
+
const days = u.last_login_days;
|
|
309
|
+
return typeof days === 'number' && days > SEGMENT_THRESHOLDS.moderateDays;
|
|
310
|
+
}, ['Churn risk', 'Low activity']),
|
|
266
311
|
];
|
|
267
312
|
}
|
|
268
313
|
|
|
@@ -275,10 +320,11 @@ export class AnalyticsEngineService {
|
|
|
275
320
|
*/
|
|
276
321
|
public predictUserBehavior(
|
|
277
322
|
user: UserData,
|
|
278
|
-
historicalData: UserData[]
|
|
323
|
+
historicalData: UserData[],
|
|
279
324
|
): UserBehaviorPrediction {
|
|
280
325
|
const similarUsers = historicalData.filter(
|
|
281
|
-
(u) =>
|
|
326
|
+
(u) =>
|
|
327
|
+
Math.abs((u.age ?? 0) - (user.age ?? 0)) < 5 && u.location === user.location,
|
|
282
328
|
);
|
|
283
329
|
|
|
284
330
|
if (similarUsers.length === 0) {
|
|
@@ -291,17 +337,18 @@ export class AnalyticsEngineService {
|
|
|
291
337
|
}
|
|
292
338
|
|
|
293
339
|
const churnCount = similarUsers.filter((u) => u.churned).length;
|
|
294
|
-
const totalLTV = similarUsers.reduce((s, u) => s + (u.lifetime_value
|
|
340
|
+
const totalLTV = similarUsers.reduce((s, u) => s + (u.lifetime_value ?? 0), 0);
|
|
295
341
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
342
|
+
const actions = similarUsers
|
|
343
|
+
.map((u) => u.last_action)
|
|
344
|
+
.filter((action): action is string => Boolean(action));
|
|
345
|
+
const actionCounts = actions.reduce<Record<string, number>>((c, a) => {
|
|
346
|
+
c[a] = (c[a] ?? 0) + 1;
|
|
300
347
|
return c;
|
|
301
348
|
}, {});
|
|
302
349
|
const nextAction = Object.keys(actionCounts).reduce(
|
|
303
350
|
(a, b) => (actionCounts[a] > actionCounts[b] ? a : b),
|
|
304
|
-
'unknown'
|
|
351
|
+
'unknown',
|
|
305
352
|
);
|
|
306
353
|
|
|
307
354
|
return {
|
|
@@ -314,6 +361,7 @@ export class AnalyticsEngineService {
|
|
|
314
361
|
}
|
|
315
362
|
|
|
316
363
|
/**
|
|
317
|
-
*
|
|
364
|
+
* Default instance for convenience.
|
|
365
|
+
* The class is also exported so consumers can instantiate a fresh one for tests.
|
|
318
366
|
*/
|
|
319
|
-
export const analyticsEngineService = AnalyticsEngineService
|
|
367
|
+
export const analyticsEngineService = new AnalyticsEngineService();
|