@umituz/web-dashboard 3.2.4 → 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
@@ -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
- * Initialize performance observers
77
+ * Register web-vitals observers (CLS / LCP / FID).
78
+ * Idempotent — calling it twice will not duplicate observers.
57
79
  */
58
- private initializeObservers(): void {
59
- if ('PerformanceObserver' in window) {
60
- // Observe layout shifts
61
- try {
62
- const observer = new PerformanceObserver((list) => {
63
- for (const entry of list.getEntries()) {
64
- if (entry.entryType === 'layout-shift') {
65
- this.recordMetric('CLS', (entry as any).value, 'score', 0.1);
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
- observer.observe({ entryTypes: ['layout-shift'] });
70
- this.observers.push(observer);
71
- } catch (e) {
72
- // Layout Shift API not supported
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
- // Observe largest contentful paint
76
- try {
77
- const observer = new PerformanceObserver((list) => {
78
- for (const entry of list.getEntries()) {
79
- if (entry.entryType === 'largest-contentful-paint') {
80
- this.recordMetric('LCP', entry.startTime, 'ms', 2500);
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
- observer.observe({ entryTypes: ['largest-contentful-paint'] });
85
- this.observers.push(observer);
86
- } catch (e) {
87
- // LCP API not supported
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
- // Observe first input delay
91
- try {
92
- const observer = new PerformanceObserver((list) => {
93
- for (const entry of list.getEntries()) {
94
- if (entry.entryType === 'first-input') {
95
- this.recordMetric('FID', (entry as any).processingStart - entry.startTime, 'ms', 100);
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
- observer.observe({ entryTypes: ['first-input'] });
100
- this.observers.push(observer);
101
- } catch (e) {
102
- // FID API not supported
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 timing = typeof window !== 'undefined' ? window.performance?.timing : null;
190
- const navigation = typeof window !== 'undefined' ? window.performance?.navigation : null;
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' || !window.performance) return;
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
- const timing = window.performance?.timing;
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, ReactElement } from "react";
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?: string;
247
+ period?: AnalyticsPeriod;
240
248
  /** Period change handler */
241
- onPeriodChange?: (period: string) => void;
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
  /**
@@ -19,6 +19,9 @@ export type {
19
19
  MetricCardProps,
20
20
  AnalyticsCardProps,
21
21
  AnalyticsLayoutProps,
22
+ AnalyticsPeriod,
23
+ AnalyticsConfig,
24
+ MetricConfig,
22
25
  DateRangePreset,
23
26
  DateRangeValue,
24
27
  ExportFormat,
@@ -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
- return num.toFixed(decimals);
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("en-US", {
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)!.push(item);
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: any = { date };
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 !== "date" && typeof value === "number") {
253
- aggregated[key] = (aggregated[key] || 0) + value;
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;
@@ -24,3 +24,5 @@ export {
24
24
  generateColor,
25
25
  generateChartColors,
26
26
  } from "./analytics";
27
+
28
+ export { ANALYTICS_KEYS } from "./i18nKeys";