@umituz/web-dashboard 3.2.4 → 4.1.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 +228 -14
- package/package.json +23 -30
- package/src/domains/analytics/components/AnalyticsCard.tsx +43 -42
- 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 +29 -87
- 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 +69 -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/index.ts +1 -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 +24 -0
- package/src/primitives/Checkbox.tsx +17 -0
- package/src/primitives/Input.tsx +23 -0
- package/src/primitives/Skeleton.tsx +6 -0
- package/src/primitives/cn.ts +10 -0
- package/src/primitives/index.ts +23 -0
- package/src/styles/dashboard.css +2731 -0
- package/src/styles/tokens.contract.test.ts +91 -0
- package/src/styles/tokens.css +116 -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
|
@@ -37,70 +37,93 @@ export interface RealtimeMetrics {
|
|
|
37
37
|
revenue: number;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Chrome-specific `performance.memory` shape (not in standard lib types).
|
|
42
|
+
*/
|
|
43
|
+
interface PerformanceMemoryLike {
|
|
44
|
+
usedJSHeapSize: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Layout-shift and first-input entry shapes (not in standard lib types
|
|
49
|
+
* for all TS versions). Structurally typed — no `any`.
|
|
50
|
+
*/
|
|
51
|
+
interface LayoutShiftEntry extends PerformanceEntry {
|
|
52
|
+
value: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface FirstInputEntry extends PerformanceEntry {
|
|
56
|
+
processingStart: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isLayoutShiftEntry = (entry: PerformanceEntry): entry is LayoutShiftEntry =>
|
|
60
|
+
typeof (entry as LayoutShiftEntry).value === 'number';
|
|
61
|
+
|
|
62
|
+
const isFirstInputEntry = (entry: PerformanceEntry): entry is FirstInputEntry =>
|
|
63
|
+
typeof (entry as FirstInputEntry).processingStart === 'number';
|
|
64
|
+
|
|
40
65
|
/**
|
|
41
66
|
* Performance Service
|
|
42
67
|
*
|
|
43
|
-
* Monitors dashboard performance and provides real-time metrics
|
|
68
|
+
* Monitors dashboard performance and provides real-time metrics.
|
|
69
|
+
* Web-vitals observation is opt-in via `startMonitoring()` so that
|
|
70
|
+
* importing the module never registers browser observers as a side effect.
|
|
44
71
|
*/
|
|
45
72
|
export class PerformanceService {
|
|
46
73
|
private metrics: Map<string, PerformanceMetric[]> = new Map();
|
|
47
74
|
private observers: PerformanceObserver[] = [];
|
|
48
75
|
|
|
49
|
-
constructor() {
|
|
50
|
-
if (typeof window !== 'undefined') {
|
|
51
|
-
this.initializeObservers();
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
76
|
/**
|
|
56
|
-
*
|
|
77
|
+
* Register web-vitals observers (CLS / LCP / FID).
|
|
78
|
+
* Idempotent — calling it twice will not duplicate observers.
|
|
57
79
|
*/
|
|
58
|
-
|
|
59
|
-
if ('PerformanceObserver' in window)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
public startMonitoring(): void {
|
|
81
|
+
if (typeof window === 'undefined' || !('PerformanceObserver' in window)) return;
|
|
82
|
+
if (this.observers.length > 0) return;
|
|
83
|
+
|
|
84
|
+
// Observe layout shifts
|
|
85
|
+
try {
|
|
86
|
+
const observer = new PerformanceObserver((list) => {
|
|
87
|
+
for (const entry of list.getEntries()) {
|
|
88
|
+
if (entry.entryType === 'layout-shift' && isLayoutShiftEntry(entry)) {
|
|
89
|
+
this.recordMetric('CLS', entry.value, 'score', 0.1);
|
|
67
90
|
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
observer.observe({ entryTypes: ['layout-shift'] });
|
|
94
|
+
this.observers.push(observer);
|
|
95
|
+
} catch {
|
|
96
|
+
// Layout Shift API not supported
|
|
97
|
+
}
|
|
74
98
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
99
|
+
// Observe largest contentful paint
|
|
100
|
+
try {
|
|
101
|
+
const observer = new PerformanceObserver((list) => {
|
|
102
|
+
for (const entry of list.getEntries()) {
|
|
103
|
+
if (entry.entryType === 'largest-contentful-paint') {
|
|
104
|
+
this.recordMetric('LCP', entry.startTime, 'ms', 2500);
|
|
82
105
|
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
observer.observe({ entryTypes: ['largest-contentful-paint'] });
|
|
109
|
+
this.observers.push(observer);
|
|
110
|
+
} catch {
|
|
111
|
+
// LCP API not supported
|
|
112
|
+
}
|
|
89
113
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
114
|
+
// Observe first input delay
|
|
115
|
+
try {
|
|
116
|
+
const observer = new PerformanceObserver((list) => {
|
|
117
|
+
for (const entry of list.getEntries()) {
|
|
118
|
+
if (entry.entryType === 'first-input' && isFirstInputEntry(entry)) {
|
|
119
|
+
this.recordMetric('FID', entry.processingStart - entry.startTime, 'ms', 100);
|
|
97
120
|
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
observer.observe({ entryTypes: ['first-input'] });
|
|
124
|
+
this.observers.push(observer);
|
|
125
|
+
} catch {
|
|
126
|
+
// FID API not supported
|
|
104
127
|
}
|
|
105
128
|
}
|
|
106
129
|
|
|
@@ -186,19 +209,12 @@ export class PerformanceService {
|
|
|
186
209
|
* @returns Dashboard performance data
|
|
187
210
|
*/
|
|
188
211
|
public getDashboardMetrics(): DashboardMetrics {
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const loadTime = timing
|
|
193
|
-
? timing.loadEventEnd - timing.navigationStart
|
|
194
|
-
: this.getLatestMetric('loadTime')?.value || 0;
|
|
212
|
+
const loadTime =
|
|
213
|
+
this.getNavigationLoadTime() ?? this.getLatestMetric('loadTime')?.value ?? 0;
|
|
195
214
|
|
|
196
215
|
const renderTime = this.getLatestMetric('renderTime')?.value || 0;
|
|
197
216
|
const apiResponseTime = this.getLatestMetric('apiResponseTime')?.value || 0;
|
|
198
|
-
const memoryUsage =
|
|
199
|
-
typeof (performance as any).memory !== 'undefined'
|
|
200
|
-
? (performance as any).memory.usedJSHeapSize / 1048576
|
|
201
|
-
: 0;
|
|
217
|
+
const memoryUsage = this.getMemoryUsageMb();
|
|
202
218
|
|
|
203
219
|
const errorRate = this.calculateErrorRate();
|
|
204
220
|
|
|
@@ -212,6 +228,31 @@ export class PerformanceService {
|
|
|
212
228
|
};
|
|
213
229
|
}
|
|
214
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Page load time from the modern Navigation Timing API.
|
|
233
|
+
* Returns null when the API or the entry is unavailable.
|
|
234
|
+
*/
|
|
235
|
+
private getNavigationLoadTime(): number | null {
|
|
236
|
+
if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// getEntriesByType is typed as PerformanceEntry[]; navigation entries
|
|
241
|
+
// are the only ones with loadEventEnd, hence the narrowing cast.
|
|
242
|
+
const [navigation] = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
|
|
243
|
+
if (!navigation || navigation.loadEventEnd <= 0) return null;
|
|
244
|
+
return navigation.loadEventEnd - navigation.startTime;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* JS heap usage in MB via the Chrome-only `performance.memory`.
|
|
249
|
+
* Returns 0 where unavailable (all other browsers, Node).
|
|
250
|
+
*/
|
|
251
|
+
private getMemoryUsageMb(): number {
|
|
252
|
+
const memory = (performance as Performance & { memory?: PerformanceMemoryLike }).memory;
|
|
253
|
+
return typeof memory !== 'undefined' ? memory.usedJSHeapSize / 1048576 : 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
215
256
|
/**
|
|
216
257
|
* Calculate error rate from metrics
|
|
217
258
|
*
|
|
@@ -280,20 +321,27 @@ export class PerformanceService {
|
|
|
280
321
|
}
|
|
281
322
|
|
|
282
323
|
/**
|
|
283
|
-
* Measure page load time
|
|
324
|
+
* Measure page load time.
|
|
325
|
+
* Records immediately if the page already loaded; otherwise listens once.
|
|
284
326
|
*/
|
|
285
327
|
public measurePageLoad(): void {
|
|
286
|
-
if (typeof window === 'undefined' ||
|
|
328
|
+
if (typeof window === 'undefined' || typeof window.performance === 'undefined') return;
|
|
329
|
+
|
|
330
|
+
const record = () => {
|
|
331
|
+
const loadTime = this.getNavigationLoadTime();
|
|
332
|
+
if (loadTime !== null) {
|
|
333
|
+
this.recordMetric('loadTime', loadTime, 'ms', 3000);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (document.readyState === 'complete') {
|
|
338
|
+
record();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
287
341
|
|
|
288
342
|
window.addEventListener('load', () => {
|
|
289
|
-
setTimeout(
|
|
290
|
-
|
|
291
|
-
if (timing) {
|
|
292
|
-
const loadTime = timing.loadEventEnd - timing.navigationStart;
|
|
293
|
-
this.recordMetric('loadTime', loadTime, 'ms', 3000);
|
|
294
|
-
}
|
|
295
|
-
}, 0);
|
|
296
|
-
});
|
|
343
|
+
setTimeout(record, 0);
|
|
344
|
+
}, { once: true });
|
|
297
345
|
}
|
|
298
346
|
|
|
299
347
|
/**
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Type definitions for analytics system
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ComponentType
|
|
7
|
+
import type { ComponentType } from "react";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Metric card data
|
|
@@ -153,6 +153,8 @@ export type ChartType =
|
|
|
153
153
|
* Chart configuration
|
|
154
154
|
*/
|
|
155
155
|
export interface ChartConfig {
|
|
156
|
+
/** Stable identifier — used as React key, also for telemetry */
|
|
157
|
+
id: string;
|
|
156
158
|
/** Chart type */
|
|
157
159
|
type: ChartType;
|
|
158
160
|
/** Chart title */
|
|
@@ -165,7 +167,7 @@ export interface ChartConfig {
|
|
|
165
167
|
xAxisKey?: string;
|
|
166
168
|
/** Y-axis keys */
|
|
167
169
|
yAxisKeys?: string[];
|
|
168
|
-
/** Color scheme */
|
|
170
|
+
/** Color scheme (CSS variable names, e.g. "hsl(var(--primary))") */
|
|
169
171
|
colors?: string[];
|
|
170
172
|
/** Show legend */
|
|
171
173
|
showLegend?: boolean;
|
|
@@ -179,6 +181,12 @@ export interface ChartConfig {
|
|
|
179
181
|
aspectRatio?: string;
|
|
180
182
|
}
|
|
181
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Supported analytics period tokens.
|
|
186
|
+
* Typed union to prevent stringly-typed comparison elsewhere.
|
|
187
|
+
*/
|
|
188
|
+
export type AnalyticsPeriod = '7d' | '30d' | '90d' | '1y' | (string & {});
|
|
189
|
+
|
|
182
190
|
/**
|
|
183
191
|
* Metric card props
|
|
184
192
|
*/
|
|
@@ -236,19 +244,21 @@ export interface AnalyticsLayoutProps {
|
|
|
236
244
|
/** Loading state */
|
|
237
245
|
loading?: boolean;
|
|
238
246
|
/** Current period */
|
|
239
|
-
period?:
|
|
247
|
+
period?: AnalyticsPeriod;
|
|
240
248
|
/** Period change handler */
|
|
241
|
-
onPeriodChange?: (period:
|
|
249
|
+
onPeriodChange?: (period: AnalyticsPeriod) => void;
|
|
242
250
|
/** Date range selector */
|
|
243
251
|
showDateRange?: boolean;
|
|
244
252
|
/** Refresh button */
|
|
245
253
|
showRefresh?: boolean;
|
|
246
254
|
/** Export button */
|
|
247
255
|
showExport?: boolean;
|
|
248
|
-
/** KPI cards */
|
|
249
|
-
kpis?: KPIs;
|
|
250
256
|
/** Charts configuration */
|
|
251
257
|
charts?: ChartConfig[];
|
|
258
|
+
/** Refresh handler — required when showRefresh is true */
|
|
259
|
+
onRefresh?: () => void;
|
|
260
|
+
/** Export handler — required when showExport is true */
|
|
261
|
+
onExport?: () => void;
|
|
252
262
|
/** Custom header content */
|
|
253
263
|
headerContent?: React.ReactNode;
|
|
254
264
|
/** Children content */
|
|
@@ -265,6 +275,10 @@ export interface DateRangePreset {
|
|
|
265
275
|
value: string;
|
|
266
276
|
/** Number of days */
|
|
267
277
|
days: number;
|
|
278
|
+
/** Range start (ISO yyyy-mm-dd), resolved at creation time */
|
|
279
|
+
from?: string;
|
|
280
|
+
/** Range end (ISO yyyy-mm-dd), resolved at creation time */
|
|
281
|
+
to?: string;
|
|
268
282
|
}
|
|
269
283
|
|
|
270
284
|
/**
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for pure analytics utility functions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
formatNumber,
|
|
8
|
+
formatPercentage,
|
|
9
|
+
formatCurrency,
|
|
10
|
+
calculateGrowth,
|
|
11
|
+
getTrend,
|
|
12
|
+
calculateConversionRate,
|
|
13
|
+
calculateDropOffRate,
|
|
14
|
+
calculateMovingAverage,
|
|
15
|
+
detectOutliers,
|
|
16
|
+
aggregateByPeriod,
|
|
17
|
+
roundTo,
|
|
18
|
+
createDateRangePreset,
|
|
19
|
+
} from "./analytics";
|
|
20
|
+
|
|
21
|
+
describe("formatNumber", () => {
|
|
22
|
+
it("keeps whole numbers below 1K whole", () => {
|
|
23
|
+
expect(formatNumber(999)).toBe("999");
|
|
24
|
+
expect(formatNumber(42)).toBe("42");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("abbreviates at the K/M/B thresholds", () => {
|
|
28
|
+
expect(formatNumber(1000)).toBe("1.0K");
|
|
29
|
+
expect(formatNumber(1500)).toBe("1.5K");
|
|
30
|
+
expect(formatNumber(2_500_000)).toBe("2.5M");
|
|
31
|
+
expect(formatNumber(3_000_000_000)).toBe("3.0B");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("respects the decimals argument", () => {
|
|
35
|
+
expect(formatNumber(1234, 2)).toBe("1.23K");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("formatPercentage", () => {
|
|
40
|
+
it("appends % to a rounded value", () => {
|
|
41
|
+
expect(formatPercentage(12.345)).toBe("12.3%");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("passes through negatives and values above 100 (generic formatter)", () => {
|
|
45
|
+
expect(formatPercentage(-5)).toBe("-5.0%");
|
|
46
|
+
expect(formatPercentage(120)).toBe("120.0%");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("formatCurrency", () => {
|
|
51
|
+
it("formats USD by default", () => {
|
|
52
|
+
expect(formatCurrency(10)).toBe("$10");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("honors the locale parameter", () => {
|
|
56
|
+
// Signature is (value, currency, decimals, locale); the assertions
|
|
57
|
+
// expect 2 decimal places, which "en-US"/"de-DE" format as ,50 / ,50.
|
|
58
|
+
expect(formatCurrency(1234.5, "USD", 2, "en-US")).toBe("$1,234.50");
|
|
59
|
+
expect(formatCurrency(1234.5, "EUR", 2, "de-DE")).toContain("1.234,50");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("calculateGrowth", () => {
|
|
64
|
+
it("treats growth from zero as 100% (or 0 when nothing grew)", () => {
|
|
65
|
+
expect(calculateGrowth(50, 0)).toBe(100);
|
|
66
|
+
expect(calculateGrowth(0, 0)).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("computes positive growth as a percentage", () => {
|
|
70
|
+
expect(calculateGrowth(150, 100)).toBe(50);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("computes negative growth", () => {
|
|
74
|
+
expect(calculateGrowth(50, 100)).toBe(-50);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("getTrend", () => {
|
|
79
|
+
it("treats ±0.1 as stable", () => {
|
|
80
|
+
expect(getTrend(0)).toBe("stable");
|
|
81
|
+
expect(getTrend(0.1)).toBe("stable");
|
|
82
|
+
expect(getTrend(-0.1)).toBe("stable");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("detects up and down beyond the threshold", () => {
|
|
86
|
+
expect(getTrend(0.2)).toBe("up");
|
|
87
|
+
expect(getTrend(-0.2)).toBe("down");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("calculateConversionRate", () => {
|
|
92
|
+
it("returns 0 for an empty total", () => {
|
|
93
|
+
expect(calculateConversionRate(5, 0)).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("computes the percentage", () => {
|
|
97
|
+
expect(calculateConversionRate(25, 100)).toBe(25);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("calculateDropOffRate", () => {
|
|
102
|
+
it("returns 0 when the previous step is 0", () => {
|
|
103
|
+
expect(calculateDropOffRate(10, 0)).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("computes drop-off between steps", () => {
|
|
107
|
+
expect(calculateDropOffRate(25, 100)).toBe(75);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("calculateMovingAverage", () => {
|
|
112
|
+
it("throws for a non-positive window", () => {
|
|
113
|
+
expect(() => calculateMovingAverage([1, 2], 0)).toThrow(RangeError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns an empty array for empty input", () => {
|
|
117
|
+
expect(calculateMovingAverage([], 3)).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("emits a trailing average per point (partial windows at the start)", () => {
|
|
121
|
+
expect(calculateMovingAverage([1, 2, 3], 2)).toEqual([1, 1.5, 2.5]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("detectOutliers", () => {
|
|
126
|
+
it("returns [] for an empty series", () => {
|
|
127
|
+
expect(detectOutliers([])).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns indices of values beyond 2 standard deviations", () => {
|
|
131
|
+
// mean ≈ 16.7, population stdev ≈ 14.9 → only 50 exceeds 2σ.
|
|
132
|
+
expect(detectOutliers([10, 10, 10, 10, 10, 50])).toEqual([5]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns no indices for a uniform series", () => {
|
|
136
|
+
expect(detectOutliers([7, 7, 7, 7])).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("aggregateByPeriod", () => {
|
|
141
|
+
it("sums numeric fields per day bucket", () => {
|
|
142
|
+
const result = aggregateByPeriod(
|
|
143
|
+
[
|
|
144
|
+
{ date: "2026-01-01", value: 10 },
|
|
145
|
+
{ date: "2026-01-01", value: 5 },
|
|
146
|
+
{ date: "2026-01-02", value: 7 },
|
|
147
|
+
],
|
|
148
|
+
"day",
|
|
149
|
+
);
|
|
150
|
+
expect(result).toEqual([
|
|
151
|
+
{ date: "2026-01-01", value: 15 },
|
|
152
|
+
{ date: "2026-01-02", value: 7 },
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("groups by month key when period is month", () => {
|
|
157
|
+
const result = aggregateByPeriod(
|
|
158
|
+
[
|
|
159
|
+
{ date: "2026-01-05", value: 1 },
|
|
160
|
+
{ date: "2026-01-25", value: 2 },
|
|
161
|
+
],
|
|
162
|
+
"month",
|
|
163
|
+
);
|
|
164
|
+
expect(result).toEqual([{ date: "2026-01", value: 3 }]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("roundTo", () => {
|
|
169
|
+
it("rounds to the given decimals", () => {
|
|
170
|
+
expect(roundTo(3.14159, 2)).toBe(3.14);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("createDateRangePreset", () => {
|
|
175
|
+
it("resolves from/to dates around today", () => {
|
|
176
|
+
const preset = createDateRangePreset("Last 7 Days", 7);
|
|
177
|
+
const today = new Date().toISOString().split("T")[0];
|
|
178
|
+
expect(preset.value).toBe("last-7-days");
|
|
179
|
+
expect(preset.to).toBe(today);
|
|
180
|
+
expect(preset.from).toBeDefined();
|
|
181
|
+
expect(preset.from).not.toBe(preset.to);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Helper functions for analytics operations
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { TimeSeriesData } from "../types/analytics";
|
|
8
|
+
|
|
7
9
|
import type { Metric, KPIData, DateRangePreset } from "../types/analytics";
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -23,7 +25,8 @@ export function formatNumber(num: number, decimals: number = 1): string {
|
|
|
23
25
|
if (num >= 1_000) {
|
|
24
26
|
return (num / 1_000).toFixed(decimals) + "K";
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
// Whole numbers stay whole ("999", not "999.0")
|
|
29
|
+
return Number.isInteger(num) ? num.toString() : num.toFixed(decimals);
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -43,14 +46,16 @@ export function formatPercentage(value: number, decimals: number = 1): string {
|
|
|
43
46
|
* @param value - Value to format
|
|
44
47
|
* @param currency - Currency code (default: USD)
|
|
45
48
|
* @param decimals - Number of decimal places (default: 0)
|
|
49
|
+
* @param locale - BCP 47 locale (default: en-US)
|
|
46
50
|
* @returns Formatted currency string
|
|
47
51
|
*/
|
|
48
52
|
export function formatCurrency(
|
|
49
53
|
value: number,
|
|
50
54
|
currency: string = "USD",
|
|
51
|
-
decimals: number = 0
|
|
55
|
+
decimals: number = 0,
|
|
56
|
+
locale: string = "en-US"
|
|
52
57
|
): string {
|
|
53
|
-
return new Intl.NumberFormat(
|
|
58
|
+
return new Intl.NumberFormat(locale, {
|
|
54
59
|
style: "currency",
|
|
55
60
|
currency,
|
|
56
61
|
minimumFractionDigits: decimals,
|
|
@@ -180,7 +185,7 @@ export function calculateDropOffRate(current: number, previous: number): number
|
|
|
180
185
|
*
|
|
181
186
|
* @param label - Preset label
|
|
182
187
|
* @param days - Number of days
|
|
183
|
-
* @returns Date range preset
|
|
188
|
+
* @returns Date range preset with resolved `from`/`to` ISO dates
|
|
184
189
|
*/
|
|
185
190
|
export function createDateRangePreset(label: string, days: number): DateRangePreset {
|
|
186
191
|
const to = new Date();
|
|
@@ -191,6 +196,8 @@ export function createDateRangePreset(label: string, days: number): DateRangePre
|
|
|
191
196
|
label,
|
|
192
197
|
value: label.toLowerCase().replace(/\s+/g, "-"),
|
|
193
198
|
days,
|
|
199
|
+
from: from.toISOString().split("T")[0],
|
|
200
|
+
to: to.toISOString().split("T")[0],
|
|
194
201
|
};
|
|
195
202
|
}
|
|
196
203
|
|
|
@@ -240,17 +247,21 @@ export function aggregateByPeriod(
|
|
|
240
247
|
if (!grouped.has(key)) {
|
|
241
248
|
grouped.set(key, []);
|
|
242
249
|
}
|
|
243
|
-
grouped.get(key)
|
|
250
|
+
const bucket = grouped.get(key);
|
|
251
|
+
if (bucket) {
|
|
252
|
+
bucket.push(item);
|
|
253
|
+
}
|
|
244
254
|
});
|
|
245
255
|
|
|
246
256
|
return Array.from(grouped.entries()).map(([date, items]) => {
|
|
247
|
-
const aggregated:
|
|
257
|
+
const aggregated: TimeSeriesData = { date };
|
|
248
258
|
|
|
249
|
-
// Sum all numeric fields
|
|
250
259
|
items.forEach((item) => {
|
|
251
260
|
Object.entries(item).forEach(([key, value]) => {
|
|
252
|
-
if (key
|
|
253
|
-
|
|
261
|
+
if (key === "date") return;
|
|
262
|
+
if (typeof value === "number") {
|
|
263
|
+
const current = (aggregated[key] as number | undefined) ?? 0;
|
|
264
|
+
aggregated[key] = current + value;
|
|
254
265
|
}
|
|
255
266
|
});
|
|
256
267
|
});
|
|
@@ -267,6 +278,10 @@ export function aggregateByPeriod(
|
|
|
267
278
|
* @returns Array of moving averages
|
|
268
279
|
*/
|
|
269
280
|
export function calculateMovingAverage(data: number[], window: number): number[] {
|
|
281
|
+
if (window < 1) {
|
|
282
|
+
throw new RangeError(`calculateMovingAverage: window must be >= 1, received ${window}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
270
285
|
const result: number[] = [];
|
|
271
286
|
|
|
272
287
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -287,6 +302,8 @@ export function calculateMovingAverage(data: number[], window: number): number[]
|
|
|
287
302
|
* @returns Array of outlier indices
|
|
288
303
|
*/
|
|
289
304
|
export function detectOutliers(data: number[], threshold: number = 2): number[] {
|
|
305
|
+
if (data.length === 0) return [];
|
|
306
|
+
|
|
290
307
|
const mean = data.reduce((sum, val) => sum + val, 0) / data.length;
|
|
291
308
|
const variance = data.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / data.length;
|
|
292
309
|
const stdDev = Math.sqrt(variance);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics Domain i18n Keys
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const ANALYTICS_KEYS = {
|
|
6
|
+
// Common
|
|
7
|
+
common: {
|
|
8
|
+
export: 'analytics.common.export',
|
|
9
|
+
refresh: 'analytics.common.refresh',
|
|
10
|
+
periodSelector: 'analytics.common.periodSelector',
|
|
11
|
+
lastPeriod: 'analytics.common.lastPeriod',
|
|
12
|
+
last7Days: 'analytics.common.last7Days',
|
|
13
|
+
last30Days: 'analytics.common.last30Days',
|
|
14
|
+
last90Days: 'analytics.common.last90Days',
|
|
15
|
+
lastYear: 'analytics.common.lastYear',
|
|
16
|
+
loading: 'analytics.common.loading',
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// Metric card
|
|
20
|
+
metric: {
|
|
21
|
+
noData: 'analytics.metric.noData',
|
|
22
|
+
change: 'analytics.metric.change',
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
// Analytics card
|
|
26
|
+
card: {
|
|
27
|
+
title: 'analytics.card.title',
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
// Chart
|
|
31
|
+
chart: {
|
|
32
|
+
noData: 'analytics.chart.noData',
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// Layout
|
|
36
|
+
layout: {
|
|
37
|
+
title: 'analytics.layout.title',
|
|
38
|
+
export: 'analytics.layout.export',
|
|
39
|
+
},
|
|
40
|
+
} as const;
|