@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.
Files changed (125) hide show
  1. package/README.md +202 -14
  2. package/package.json +21 -29
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
  4. package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
  5. package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
  6. package/src/domains/analytics/components/MetricCard.tsx +42 -64
  7. package/src/domains/analytics/hooks/index.ts +7 -1
  8. package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
  9. package/src/domains/analytics/index.ts +11 -0
  10. package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
  11. package/src/domains/analytics/services/PerformanceService.ts +117 -69
  12. package/src/domains/analytics/types/analytics.ts +20 -6
  13. package/src/domains/analytics/types/index.ts +3 -0
  14. package/src/domains/analytics/utils/analytics.test.ts +183 -0
  15. package/src/domains/analytics/utils/analytics.ts +26 -9
  16. package/src/domains/analytics/utils/i18nKeys.ts +40 -0
  17. package/src/domains/analytics/utils/index.ts +2 -0
  18. package/src/domains/auth/components/AuthLayout.tsx +63 -54
  19. package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
  20. package/src/domains/auth/components/LoginForm.tsx +167 -226
  21. package/src/domains/auth/components/RegisterForm.tsx +202 -272
  22. package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
  23. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
  24. package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
  25. package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
  26. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
  27. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
  28. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
  29. package/src/domains/auth/components/shared/index.ts +20 -0
  30. package/src/domains/auth/hooks/index.ts +1 -0
  31. package/src/domains/auth/hooks/useAuth.ts +151 -232
  32. package/src/domains/auth/index.ts +8 -4
  33. package/src/domains/auth/types/auth.ts +26 -5
  34. package/src/domains/auth/types/index.ts +1 -0
  35. package/src/domains/auth/utils/auth.test.ts +182 -0
  36. package/src/domains/auth/utils/auth.ts +71 -130
  37. package/src/domains/auth/utils/i18nKeys.ts +127 -0
  38. package/src/domains/auth/utils/index.ts +7 -1
  39. package/src/domains/auth/utils/secureToken.ts +33 -0
  40. package/src/domains/auth/utils/validationKeys.ts +23 -0
  41. package/src/domains/calendar/hooks/useCalendar.ts +145 -151
  42. package/src/domains/calendar/index.ts +0 -1
  43. package/src/domains/calendar/services/CalendarService.ts +122 -179
  44. package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
  45. package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
  46. package/src/domains/calendar/utils/index.ts +33 -30
  47. package/src/domains/layouts/components/BrandLogo.tsx +12 -9
  48. package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
  49. package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
  50. package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
  51. package/src/domains/layouts/types/layout.ts +7 -1
  52. package/src/domains/layouts/types/user.ts +3 -1
  53. package/src/domains/modules/build.test.tsx +97 -0
  54. package/src/domains/modules/build.tsx +71 -0
  55. package/src/domains/modules/index.ts +2 -0
  56. package/src/domains/modules/types.ts +29 -0
  57. package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
  58. package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
  59. package/src/domains/onboarding/components/PlanStep.tsx +156 -123
  60. package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
  61. package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
  62. package/src/domains/onboarding/components/StepProgress.tsx +62 -0
  63. package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
  64. package/src/domains/onboarding/components/index.ts +4 -0
  65. package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
  66. package/src/domains/onboarding/index.ts +1 -0
  67. package/src/domains/onboarding/types/onboarding.ts +57 -17
  68. package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
  69. package/src/domains/onboarding/utils/index.ts +2 -0
  70. package/src/domains/permissions/PermissionsContext.tsx +103 -0
  71. package/src/domains/permissions/hasPermission.test.ts +50 -0
  72. package/src/domains/permissions/hasPermission.ts +38 -0
  73. package/src/domains/permissions/index.ts +19 -0
  74. package/src/domains/permissions/types.ts +35 -0
  75. package/src/domains/permissions/utils/i18nKeys.ts +11 -0
  76. package/src/domains/session/DemoLogin.tsx +50 -0
  77. package/src/domains/session/OrgSwitcher.tsx +110 -0
  78. package/src/domains/session/SessionContext.tsx +150 -0
  79. package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
  80. package/src/domains/session/index.ts +18 -0
  81. package/src/domains/session/types.ts +47 -0
  82. package/src/domains/session/utils/i18nKeys.ts +12 -0
  83. package/src/domains/settings/components/AuditLogList.tsx +59 -0
  84. package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
  85. package/src/domains/settings/components/SettingsLayout.tsx +75 -60
  86. package/src/domains/settings/components/SettingsSection.tsx +35 -22
  87. package/src/domains/settings/components/UserManagementTable.tsx +87 -0
  88. package/src/domains/settings/components/index.ts +10 -0
  89. package/src/domains/settings/hooks/useSettings.ts +77 -52
  90. package/src/domains/settings/types/settings.ts +2 -0
  91. package/src/domains/settings/utils/i18nKeys.ts +18 -0
  92. package/src/domains/settings/utils/settings.ts +11 -4
  93. package/src/i18n/I18nContext.tsx +33 -0
  94. package/src/i18n/defaultT.test.ts +43 -0
  95. package/src/i18n/defaultT.ts +34 -0
  96. package/src/i18n/index.ts +7 -0
  97. package/src/i18n/types.ts +13 -0
  98. package/src/index.ts +1 -7
  99. package/src/primitives/Button.tsx +30 -0
  100. package/src/primitives/Checkbox.tsx +40 -0
  101. package/src/primitives/Input.tsx +19 -0
  102. package/src/primitives/Skeleton.tsx +26 -0
  103. package/src/primitives/cn.ts +15 -0
  104. package/src/primitives/index.ts +23 -0
  105. package/src/styles/dashboard.css +3045 -0
  106. package/src/styles/tokens.contract.test.ts +66 -0
  107. package/src/styles/tokens.css +124 -0
  108. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  109. package/src/domains/billing/components/BillingPage.tsx +0 -49
  110. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  111. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  112. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  113. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  114. package/src/domains/billing/components/UsageCard.tsx +0 -103
  115. package/src/domains/billing/components/index.ts +0 -13
  116. package/src/domains/billing/constants/billing.ts +0 -69
  117. package/src/domains/billing/hooks/index.ts +0 -7
  118. package/src/domains/billing/hooks/useBilling.ts +0 -385
  119. package/src/domains/billing/index.ts +0 -73
  120. package/src/domains/billing/types/billing.ts +0 -342
  121. package/src/domains/billing/types/index.ts +0 -30
  122. package/src/domains/billing/utils/billing.ts +0 -360
  123. package/src/domains/billing/utils/index.ts +0 -31
  124. package/src/domains/billing/utils/polar.ts +0 -63
  125. package/src/domains/calendar/components/index.ts +0 -7
@@ -1,178 +1,183 @@
1
1
  /**
2
- * useAnalytics Hook
2
+ * useAnalytics
3
3
  *
4
- * Core analytics hook for fetching and managing analytics data
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
- interface UseAnalyticsOptions {
18
- /** Analytics API base URL */
19
- apiUrl?: string;
20
- /** Initial date range */
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 AnalyticsData {
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
- * useAnalytics hook
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
- // State
49
- const [dateRange, setDateRange] = useState<DateRangeValue>(
50
- initialDateRange || {
51
- from: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0],
52
- to: new Date().toISOString().split("T")[0],
53
- preset: "Last 30 Days",
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
- // Fetch analytics data
70
- const fetchAnalytics = useCallback(async () => {
71
- setData((prev) => ({ ...prev, isLoading: true, error: null }));
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
- try {
74
- // In production, call your analytics API
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
- // Update date range
117
- const updateDateRange = useCallback((newDateRange: DateRangeValue) => {
118
- setDateRange(newDateRange);
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
- // Export analytics data
122
- const exportData = useCallback(async (options: AnalyticsExportOptions) => {
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
- // In production, implement export API call
125
- const exportData = {
126
- dateRange,
127
- kpis: data.kpis,
128
- timeSeries: data.timeSeries,
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
- console.error("Export failed:", err);
149
- throw err;
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
- }, [dateRange, data]);
112
+ }, [apiClient, dateRange]);
152
113
 
153
- // Refresh data
154
- const refresh = useCallback(() => {
155
- fetchAnalytics();
156
- }, [fetchAnalytics]);
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
- fetchAnalytics();
161
- }, [fetchAnalytics]);
144
+ refresh();
145
+ }, [refresh]);
162
146
 
163
- // Auto-refresh
164
147
  useEffect(() => {
165
148
  if (refreshInterval > 0) {
166
- const interval = setInterval(fetchAnalytics, refreshInterval);
167
- return () => clearInterval(interval);
149
+ const id = setInterval(refresh, refreshInterval);
150
+ return () => clearInterval(id);
168
151
  }
169
- }, [fetchAnalytics, refreshInterval]);
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
- return {
172
- ...data,
173
- dateRange,
174
- updateDateRange,
175
- refresh,
176
- exportData,
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
- * Analytics Engine Service
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
- export class AnalyticsEngineService {
68
- private static instance: AnalyticsEngineService;
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
- private constructor() {}
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
- public static getInstance(): AnalyticsEngineService {
73
- if (!AnalyticsEngineService.instance) {
74
- AnalyticsEngineService.instance = new AnalyticsEngineService();
75
- }
76
- return AnalyticsEngineService.instance;
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(signupDate.getFullYear(), signupDate.getMonth() + month, 1);
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.reduce((a, b) => a + b, 0) / retention.length,
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 || 0;
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 ? ((totalConversions - path.count) / totalConversions) * 100 : 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 || 0), 0) / stepData.length
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 || 0,
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
- return d.getDay() === day && d.getHours() === hour;
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 avg = (field: string) =>
237
- users.length === 0
238
- ? 0
239
- : users.reduce((s, i) => s + ((i as any)[field] || 0), 0) / users.length;
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: avg('session_duration'),
248
- pagesPerSession: avg('pages_per_session'),
249
- bounceRate: avg('bounce_rate'),
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) => u.last_login_days! <= 7, [
256
- 'Recent login',
257
- 'High engagement',
258
- ]),
259
- defineSegment('Moderate', (u) => u.last_login_days! > 7 && u.last_login_days! <= 30, [
260
- 'Occasional login',
261
- ]),
262
- defineSegment('Inactive', (u) => u.last_login_days! > 30, [
263
- 'Churn risk',
264
- 'Low activity',
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) => Math.abs((u.age || 0) - (user.age || 0)) < 5 && u.location === user.location
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 || 0), 0);
340
+ const totalLTV = similarUsers.reduce((s, u) => s + (u.lifetime_value ?? 0), 0);
295
341
 
296
- // Predict next action based on frequency in similar users
297
- const actions = similarUsers.map((u) => u.last_action).filter(Boolean) as string[];
298
- const actionCounts = actions.reduce((c: Record<string, number>, a) => {
299
- c[a] = (c[a] || 0) + 1;
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
- * Singleton instance
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.getInstance();
367
+ export const analyticsEngineService = new AnalyticsEngineService();