@umituz/web-dashboard 6.0.0 → 6.2.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 (133) hide show
  1. package/README.md +47 -73
  2. package/package.json +15 -7
  3. package/src/config/DashboardConfig.ts +19 -68
  4. package/src/domains/analytics/components/index.ts +0 -6
  5. package/src/domains/analytics/hooks/index.ts +0 -6
  6. package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
  7. package/src/domains/analytics/index.ts +0 -10
  8. package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
  9. package/src/domains/analytics/services/PerformanceService.ts +27 -138
  10. package/src/domains/analytics/services/index.ts +0 -4
  11. package/src/domains/analytics/types/analytics.ts +100 -270
  12. package/src/domains/analytics/types/index.ts +0 -6
  13. package/src/domains/analytics/utils/analytics.test.ts +2 -9
  14. package/src/domains/analytics/utils/analytics.ts +1 -22
  15. package/src/domains/analytics/utils/index.ts +0 -6
  16. package/src/domains/calendar/config.ts +0 -7
  17. package/src/domains/calendar/hooks/index.ts +0 -4
  18. package/src/domains/calendar/hooks/useCalendar.ts +7 -27
  19. package/src/domains/calendar/index.ts +0 -4
  20. package/src/domains/calendar/services/CalendarService.ts +0 -21
  21. package/src/domains/calendar/services/index.ts +0 -4
  22. package/src/domains/calendar/types/calendar.types.ts +9 -53
  23. package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
  24. package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
  25. package/src/domains/calendar/utils/index.ts +0 -26
  26. package/src/domains/layouts/components/index.ts +0 -4
  27. package/src/domains/layouts/hooks/dashboard.ts +0 -21
  28. package/src/domains/layouts/hooks/index.ts +0 -4
  29. package/src/domains/layouts/index.ts +0 -6
  30. package/src/domains/layouts/theme/default.ts +0 -12
  31. package/src/domains/layouts/theme/index.ts +0 -4
  32. package/src/domains/layouts/theme/presets.ts +0 -9
  33. package/src/domains/layouts/theme/utils.ts +0 -15
  34. package/src/domains/layouts/types/index.ts +0 -4
  35. package/src/domains/layouts/types/layout.ts +18 -56
  36. package/src/domains/layouts/types/notification.ts +4 -17
  37. package/src/domains/layouts/types/sidebar.ts +8 -28
  38. package/src/domains/layouts/types/theme.ts +23 -59
  39. package/src/domains/layouts/types/user.ts +9 -30
  40. package/src/domains/layouts/utils/dashboard.ts +4 -48
  41. package/src/domains/layouts/utils/index.ts +0 -4
  42. package/src/domains/modules/build.tsx +2 -16
  43. package/src/domains/modules/types.ts +8 -23
  44. package/src/domains/onboarding/calc/calc.test.ts +173 -0
  45. package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
  46. package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
  47. package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
  48. package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
  49. package/src/domains/onboarding/calc/index.ts +36 -0
  50. package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
  51. package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
  52. package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
  53. package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
  54. package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
  55. package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
  56. package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
  57. package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
  58. package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
  59. package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
  60. package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
  61. package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
  62. package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
  63. package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
  64. package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
  65. package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
  66. package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
  67. package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
  68. package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
  69. package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
  70. package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
  71. package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
  72. package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
  73. package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
  74. package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
  75. package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
  76. package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
  77. package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
  78. package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
  79. package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
  80. package/src/domains/onboarding/components/screens/index.ts +28 -0
  81. package/src/domains/onboarding/hooks/index.ts +7 -0
  82. package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
  83. package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
  84. package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
  85. package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
  86. package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
  87. package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
  88. package/src/domains/onboarding/index.ts +153 -0
  89. package/src/domains/onboarding/predicates/has-handler.ts +21 -0
  90. package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
  91. package/src/domains/onboarding/predicates/index.ts +26 -0
  92. package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
  93. package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
  94. package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
  95. package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
  96. package/src/domains/onboarding/predicates/storage-available.ts +14 -0
  97. package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
  98. package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
  99. package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
  100. package/src/domains/onboarding/templates/index.ts +6 -0
  101. package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
  102. package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
  103. package/src/domains/onboarding/test/setup.ts +23 -0
  104. package/src/domains/onboarding/types/config.ts +57 -0
  105. package/src/domains/onboarding/types/index.ts +10 -0
  106. package/src/domains/onboarding/types/labels.ts +49 -0
  107. package/src/domains/onboarding/types/screens.ts +185 -0
  108. package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
  109. package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
  110. package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
  111. package/src/domains/onboarding/utils/index.ts +12 -0
  112. package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
  113. package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
  114. package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
  115. package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
  116. package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
  117. package/src/domains/onboarding/validation/index.ts +9 -0
  118. package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
  119. package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
  120. package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
  121. package/src/domains/onboarding/validation/validation.test.ts +170 -0
  122. package/src/domains/permissions/hasPermission.ts +0 -8
  123. package/src/domains/permissions/types.ts +0 -20
  124. package/src/domains/settings/components/index.ts +0 -4
  125. package/src/domains/settings/hooks/index.ts +0 -4
  126. package/src/domains/settings/hooks/useSettings.ts +1 -26
  127. package/src/domains/settings/index.ts +0 -6
  128. package/src/domains/settings/types/index.ts +0 -4
  129. package/src/domains/settings/types/settings.ts +23 -67
  130. package/src/domains/settings/utils/index.ts +0 -4
  131. package/src/domains/settings/utils/settings.ts +0 -45
  132. package/src/styles/dashboard.contract.test.ts +1 -0
  133. package/src/styles/dashboard.css +818 -1
@@ -1,37 +1,16 @@
1
- /**
2
- * Dashboard Types - User
3
- *
4
- * Type definitions for user profile
5
- */
6
-
7
1
  import type { ComponentType } from "react";
8
2
 
9
- /**
10
- * User profile info for header
11
- */
12
3
  export interface DashboardUser {
13
- /** User ID */
14
- id: string;
15
- /** Display name */
16
- name?: string;
17
- /** Email address */
18
- email?: string;
19
- /** Avatar URL */
20
- avatar?: string;
21
- /** Whether user has mobile app access */
22
- hasMobileApp?: boolean;
23
- /** Whether user has web app access */
24
- hasWebApp?: boolean;
4
+ id: string;
5
+ name?: string;
6
+ email?: string;
7
+ avatar?: string;
8
+ hasMobileApp?: boolean;
9
+ hasWebApp?: boolean;
25
10
  }
26
11
 
27
- /**
28
- * Navigation item for user menu
29
- */
30
12
  export interface UserNavMenuItem {
31
- /** Display label */
32
- label: string;
33
- /** Icon component */
34
- icon: ComponentType<{ className?: string }>;
35
- /** Route path */
36
- path: string;
13
+ label: string;
14
+ icon: ComponentType<{ className?: string }>;
15
+ path: string;
37
16
  }
@@ -1,16 +1,3 @@
1
- /**
2
- * Dashboard Utilities
3
- *
4
- * Utility functions for dashboard operations
5
- */
6
-
7
- /**
8
- * Format notification timestamp to relative time
9
- *
10
- * @param createdAt - Notification creation timestamp
11
- * @param t - i18n translation function
12
- * @returns Formatted time string
13
- */
14
1
  export function formatNotificationTime(
15
2
  createdAt: Date | string | number,
16
3
  t: (key: string, params?: Record<string, unknown>) => string
@@ -24,61 +11,35 @@ export function formatNotificationTime(
24
11
  return t("dashboard.activityFeed.times.daysAgo", { val: Math.floor(secs / 86400) });
25
12
  }
26
13
 
27
- /**
28
- * Check if a path is active
29
- *
30
- * @param currentPath - Current location pathname
31
- * @param targetPath - Target path to check
32
- * @returns True if paths match
33
- */
34
14
  export function isPathActive(currentPath: string, targetPath: string): boolean {
35
15
  return currentPath === targetPath;
36
16
  }
37
17
 
38
- /**
39
- * Get page title from sidebar configuration
40
- *
41
- * @param pathname - Current pathname
42
- * @param sidebarGroups - Sidebar groups configuration
43
- * @param extraTitleMap - Extra title mappings
44
- * @returns Page title or null
45
- */
46
18
  export function getPageTitle(
47
19
  pathname: string,
48
20
  sidebarGroups: Array<{ items: Array<{ path: string; label: string }> }>,
49
21
  extraTitleMap?: Record<string, string>
50
22
  ): string | null {
51
- // Search in sidebar groups
52
- for (const group of sidebarGroups) {
23
+ for (const group of sidebarGroups) {
53
24
  const item = group.items.find((i) => i.path === pathname);
54
25
  if (item) return item.label;
55
26
  }
56
27
 
57
- // Search in extra title map
58
- if (extraTitleMap?.[pathname]) {
28
+ if (extraTitleMap?.[pathname]) {
59
29
  return extraTitleMap[pathname];
60
30
  }
61
31
 
62
32
  return null;
63
33
  }
64
34
 
65
- /**
66
- * Filter sidebar items by app type and enabled status
67
- *
68
- * @param items - Sidebar items to filter
69
- * @param user - Current user object
70
- * @returns Filtered items
71
- */
72
35
  export function filterSidebarItems<T extends { enabled?: boolean; requiredApp?: "mobile" | "web" }>(
73
36
  items: T[],
74
37
  user?: { hasMobileApp?: boolean; hasWebApp?: boolean }
75
38
  ): T[] {
76
39
  return items.filter((item) => {
77
- // Skip disabled items
78
- if (item.enabled === false) return false;
40
+ if (item.enabled === false) return false;
79
41
 
80
- // Skip items that require specific app types
81
- if (!item.requiredApp) return true;
42
+ if (!item.requiredApp) return true;
82
43
  if (item.requiredApp === "mobile") return user?.hasMobileApp ?? false;
83
44
  if (item.requiredApp === "web") return user?.hasWebApp ?? false;
84
45
 
@@ -86,11 +47,6 @@ export function filterSidebarItems<T extends { enabled?: boolean; requiredApp?:
86
47
  });
87
48
  }
88
49
 
89
- /**
90
- * Generate notification ID
91
- *
92
- * @returns Unique notification ID
93
- */
94
50
  export function generateNotificationId(): string {
95
51
  return crypto.randomUUID();
96
52
  }
@@ -1,7 +1,3 @@
1
- /**
2
- * Layout Utils Export
3
- */
4
-
5
1
  export {
6
2
  formatNotificationTime,
7
3
  isPathActive,
@@ -1,14 +1,3 @@
1
- /**
2
- * buildRoutes / buildNav — derive route configs and sidebar groups
3
- * from a single DashboardModule list.
4
- *
5
- * - buildRoutes wraps every permissioned module's element in
6
- * <RequirePermission>, so hosts only map the result to <Route>.
7
- * - buildNav filters by the same permissions via `can`, hides
8
- * inNav:false detail routes, groups by navGroup (first-appearance
9
- * order) and sorts items by `order` (stable for ties).
10
- */
11
-
12
1
  import type { ReactNode } from "react";
13
2
  import type { SidebarGroup } from "../layouts/types";
14
3
  import { RequirePermission } from "../permissions";
@@ -33,8 +22,7 @@ export const buildRoutes = (modules: readonly DashboardModule[]): RouteConfig[]
33
22
  }));
34
23
 
35
24
  export interface BuildNavOptions {
36
- /** Permission check, e.g. usePermissions().can. */
37
- can: (permission: Permission) => boolean;
25
+ can: (permission: Permission) => boolean;
38
26
  }
39
27
 
40
28
  export const buildNav = (
@@ -45,9 +33,7 @@ export const buildNav = (
45
33
  .filter((module) => module.inNav !== false)
46
34
  .filter((module) => !module.permission || can(module.permission));
47
35
 
48
- // Groups keep their first-appearance order in the module list;
49
- // `order` sorts items within their group.
50
- const grouped = new Map<string, DashboardModule[]>();
36
+ const grouped = new Map<string, DashboardModule[]>();
51
37
  for (const module of visible) {
52
38
  const items = grouped.get(module.navGroup) ?? [];
53
39
  items.push(module);
@@ -1,29 +1,14 @@
1
- /**
2
- * Dashboard module model — the single source of truth a host app
3
- * registers once per module; routes and sidebar nav are both derived
4
- * from it (see build.ts). Generalizes the texora/visora pattern
5
- * where MODULE_ROUTES and NAV_GROUPS were hand-kept in sync.
6
- */
7
-
8
1
  import type { ReactNode } from "react";
9
2
  import type { LucideIcon } from "lucide-react";
10
3
  import type { Permission } from "../permissions/types";
11
4
 
12
5
  export interface DashboardModule {
13
- /** Route path (may contain params, e.g. "/sales/:id"). */
14
- path: string;
15
- /** i18n key (or plain label) for nav + page headers. */
16
- labelKey: string;
17
- /** lucide-react icon for the nav item. */
18
- icon: LucideIcon;
19
- /** Nav group title key — modules with the same key land in one group. */
20
- navGroup: string;
21
- /** Page content; permission-guarded automatically when `permission` is set. */
22
- element: ReactNode;
23
- /** Permission required to see/use this module (optional). */
24
- permission?: Permission;
25
- /** Sort weight inside its nav group (lower first, default last). */
26
- order?: number;
27
- /** Include in the nav (default true — set false for detail routes). */
28
- inNav?: boolean;
6
+ path: string;
7
+ labelKey: string;
8
+ icon: LucideIcon;
9
+ navGroup: string;
10
+ element: ReactNode;
11
+ permission?: Permission;
12
+ order?: number;
13
+ inNav?: boolean;
29
14
  }
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import {
3
+ computeProgressFraction,
4
+ computeProgressPercentage,
5
+ computeRemainingSteps,
6
+ } from './progress-percentage';
7
+ import {
8
+ advanceStepIndex,
9
+ clampStepIndex,
10
+ retreatStepIndex,
11
+ } from './step-index-clamp';
12
+ import {
13
+ didCrossLeftSwipeThreshold,
14
+ didCrossRightSwipeThreshold,
15
+ SWIPE_DECISION_THRESHOLD_PX,
16
+ } from './swipe-gesture-curve';
17
+ import {
18
+ clampValueToBounds,
19
+ resolveSliderBounds,
20
+ areSliderBoundsValid,
21
+ } from './slider-value-bounds';
22
+ import {
23
+ getDefaultStepDurationMs,
24
+ splitTotalDuration,
25
+ } from './duration-equal-split';
26
+ import { elapsedMillis } from './elapsed-millis';
27
+ import { formatCountWithLocale } from './count-with-locale';
28
+ import {
29
+ getCardEnterAnimate,
30
+ getCardEnterExit,
31
+ getCardEnterInitial,
32
+ getCardTransitionDurationMs,
33
+ } from './card-stack-motion-curve';
34
+
35
+ describe('progress-percentage (§22.1)', () => {
36
+ it('computes percentages without NaN or Infinity', () => {
37
+ expect(computeProgressPercentage(0, 5)).toBe(0);
38
+ expect(computeProgressPercentage(5, 5)).toBe(100);
39
+ expect(computeProgressPercentage(0, 0)).toBe(0);
40
+ expect(computeProgressPercentage(-1, 5)).toBe(0);
41
+ expect(computeProgressPercentage(10, 5)).toBe(100);
42
+ expect(Number.isFinite(computeProgressPercentage(3, 7))).toBe(true);
43
+ });
44
+
45
+ it('computes the 0..1 fraction', () => {
46
+ expect(computeProgressFraction(1, 4)).toBe(0.25);
47
+ expect(computeProgressFraction(0, 0)).toBe(0);
48
+ });
49
+
50
+ it('counts remaining steps including the current one', () => {
51
+ expect(computeRemainingSteps(0, 5)).toBe(5);
52
+ expect(computeRemainingSteps(3, 5)).toBe(2);
53
+ expect(computeRemainingSteps(9, 5)).toBe(0);
54
+ expect(computeRemainingSteps(0, 0)).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe('step-index-clamp (§22.2)', () => {
59
+ it('clamps arbitrary indices into range', () => {
60
+ expect(clampStepIndex(0, 5)).toBe(0);
61
+ expect(clampStepIndex(4, 5)).toBe(4);
62
+ expect(clampStepIndex(10, 5)).toBe(4);
63
+ expect(clampStepIndex(-1, 5)).toBe(0);
64
+ expect(clampStepIndex(0, 0)).toBe(0);
65
+ });
66
+
67
+ it('advances without going past the last step', () => {
68
+ expect(advanceStepIndex(3, 5)).toBe(4);
69
+ expect(advanceStepIndex(4, 5)).toBe(4);
70
+ expect(advanceStepIndex(0, 0)).toBe(0);
71
+ });
72
+
73
+ it('retreats without going below the first step', () => {
74
+ expect(retreatStepIndex(0)).toBe(0);
75
+ expect(retreatStepIndex(3)).toBe(2);
76
+ });
77
+ });
78
+
79
+ describe('swipe-gesture-curve (§22.3)', () => {
80
+ it('registers right swipes only above the threshold', () => {
81
+ expect(didCrossRightSwipeThreshold(50)).toBe(false);
82
+ expect(didCrossRightSwipeThreshold(120)).toBe(false); expect(didCrossRightSwipeThreshold(150)).toBe(true);
83
+ });
84
+
85
+ it('registers left swipes only below the negative threshold', () => {
86
+ expect(didCrossLeftSwipeThreshold(-50)).toBe(false);
87
+ expect(didCrossLeftSwipeThreshold(-150)).toBe(true);
88
+ });
89
+
90
+ it('exposes the threshold as a single named constant', () => {
91
+ expect(SWIPE_DECISION_THRESHOLD_PX).toBe(120);
92
+ });
93
+ });
94
+
95
+ describe('slider-value-bounds (§22.4)', () => {
96
+ it('clamps values into bounds', () => {
97
+ expect(clampValueToBounds(50, 0, 100, 'x')).toBe(50);
98
+ expect(clampValueToBounds(-5, 0, 100, 'x')).toBe(0);
99
+ expect(clampValueToBounds(200, 0, 100, 'x')).toBe(100);
100
+ });
101
+
102
+ it('surfaces min > max misconfigurations via console.error, value as-is', () => {
103
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
104
+ try {
105
+ expect(clampValueToBounds(50, 100, 10, 'mood')).toBe(50);
106
+ expect(errorSpy).toHaveBeenCalledTimes(1);
107
+ expect(errorSpy.mock.calls[0][0]).toContain('mood');
108
+ expect(errorSpy.mock.calls[0][0]).toContain('min > max');
109
+ } finally {
110
+ errorSpy.mockRestore();
111
+ }
112
+ });
113
+
114
+ it('resolves unset bounds to sensible defaults', () => {
115
+ expect(resolveSliderBounds(undefined, undefined)).toEqual({ min: 0, max: 100 });
116
+ expect(areSliderBoundsValid(0, 100)).toBe(true);
117
+ expect(areSliderBoundsValid(100, 10)).toBe(false);
118
+ });
119
+ });
120
+
121
+ describe('duration-equal-split (§22.5)', () => {
122
+ it('splits a total duration evenly', () => {
123
+ expect(splitTotalDuration(3000, 3)).toBe(1000);
124
+ });
125
+
126
+ it('falls back to the default per-step duration when unset or invalid', () => {
127
+ expect(splitTotalDuration(0, 3)).toBe(1500);
128
+ expect(splitTotalDuration(undefined, 3)).toBe(1500);
129
+ expect(getDefaultStepDurationMs()).toBe(1500);
130
+ });
131
+
132
+ it('never divides by zero', () => {
133
+ expect(splitTotalDuration(3000, 0)).toBe(3000);
134
+ });
135
+ });
136
+
137
+ describe('count-with-locale (§22.6)', () => {
138
+ it('formats counts for human display', () => {
139
+ expect(formatCountWithLocale(250000, 'en-US')).toBe('250,000');
140
+ });
141
+
142
+ it('formats with an explicit non-English locale', () => {
143
+ expect(formatCountWithLocale(250000, 'de-DE')).toBe('250.000');
144
+ });
145
+
146
+ it('maps non-finite input to "0"', () => {
147
+ expect(formatCountWithLocale(Number.NaN)).toBe('0');
148
+ expect(formatCountWithLocale(Number.POSITIVE_INFINITY)).toBe('0');
149
+ });
150
+ });
151
+
152
+ describe('elapsed-millis (§22.7)', () => {
153
+ it('returns the positive difference', () => {
154
+ const start = new Date('2026-01-01T10:00:00Z');
155
+ const end = new Date('2026-01-01T10:00:30Z');
156
+ expect(elapsedMillis(start, end)).toBe(30_000);
157
+ });
158
+
159
+ it('clamps negative differences to zero', () => {
160
+ const start = new Date('2026-01-01T10:00:30Z');
161
+ const end = new Date('2026-01-01T10:00:00Z');
162
+ expect(elapsedMillis(start, end)).toBe(0);
163
+ });
164
+ });
165
+
166
+ describe('card-stack-motion-curve', () => {
167
+ it('exposes consistent enter/exit states', () => {
168
+ expect(getCardEnterInitial()).toEqual({ opacity: 0, scale: 0.95 });
169
+ expect(getCardEnterAnimate()).toEqual({ opacity: 1, scale: 1 });
170
+ expect(getCardEnterExit()).toEqual({ opacity: 0, scale: 1.05 });
171
+ expect(getCardTransitionDurationMs()).toBeGreaterThan(0);
172
+ });
173
+ });
@@ -0,0 +1,27 @@
1
+ const MOTION_ENTER_DURATION_MS = 250;
2
+ const INITIAL_SCALE = 0.95;
3
+ const STEADY_SCALE = 1;
4
+ const EXIT_SCALE = 1.05;
5
+ const HIDDEN_OPACITY = 0;
6
+ const VISIBLE_OPACITY = 1;
7
+
8
+ export interface CardMotionState {
9
+ readonly opacity: number;
10
+ readonly scale: number;
11
+ }
12
+
13
+ export function getCardEnterInitial(): CardMotionState {
14
+ return { opacity: HIDDEN_OPACITY, scale: INITIAL_SCALE };
15
+ }
16
+
17
+ export function getCardEnterAnimate(): CardMotionState {
18
+ return { opacity: VISIBLE_OPACITY, scale: STEADY_SCALE };
19
+ }
20
+
21
+ export function getCardEnterExit(): CardMotionState {
22
+ return { opacity: HIDDEN_OPACITY, scale: EXIT_SCALE };
23
+ }
24
+
25
+ export function getCardTransitionDurationMs(): number {
26
+ return MOTION_ENTER_DURATION_MS;
27
+ }
@@ -0,0 +1,8 @@
1
+ export function formatCountWithLocale(value: number, locale?: string): string {
2
+ if (!Number.isFinite(value)) return '0';
3
+ try {
4
+ return value.toLocaleString(locale);
5
+ } catch {
6
+ return value.toLocaleString('en-US');
7
+ }
8
+ }
@@ -0,0 +1,12 @@
1
+ const DEFAULT_STEP_DURATION_MS = 1500;
2
+ const MIN_PARTS = 1;
3
+
4
+ export function splitTotalDuration(totalMs: number | undefined, parts: number): number {
5
+ if (totalMs === undefined || totalMs <= 0) return DEFAULT_STEP_DURATION_MS;
6
+ const safeParts = Math.max(MIN_PARTS, parts);
7
+ return totalMs / safeParts;
8
+ }
9
+
10
+ export function getDefaultStepDurationMs(): number {
11
+ return DEFAULT_STEP_DURATION_MS;
12
+ }
@@ -0,0 +1,6 @@
1
+ const ZERO_MS = 0;
2
+
3
+ export function elapsedMillis(start: Date, end: Date): number {
4
+ const diff = end.getTime() - start.getTime();
5
+ return diff > ZERO_MS ? diff : ZERO_MS;
6
+ }
@@ -0,0 +1,36 @@
1
+ export {
2
+ computeProgressFraction,
3
+ computeProgressPercentage,
4
+ computeRemainingSteps,
5
+ } from './progress-percentage';
6
+ export {
7
+ clampStepIndex,
8
+ advanceStepIndex,
9
+ retreatStepIndex,
10
+ } from './step-index-clamp';
11
+ export {
12
+ SWIPE_DECISION_THRESHOLD_PX,
13
+ SWIPE_ROTATION_RANGE,
14
+ SWIPE_ROTATION_VALUES,
15
+ SWIPE_LEFT_OPACITY_INPUT,
16
+ SWIPE_LEFT_OPACITY_VALUES,
17
+ SWIPE_RIGHT_OPACITY_INPUT,
18
+ SWIPE_RIGHT_OPACITY_VALUES,
19
+ didCrossRightSwipeThreshold,
20
+ didCrossLeftSwipeThreshold,
21
+ } from './swipe-gesture-curve';
22
+ export type { CardMotionState } from './card-stack-motion-curve';
23
+ export {
24
+ getCardEnterInitial,
25
+ getCardEnterAnimate,
26
+ getCardEnterExit,
27
+ getCardTransitionDurationMs,
28
+ } from './card-stack-motion-curve';
29
+ export {
30
+ resolveSliderBounds,
31
+ areSliderBoundsValid,
32
+ clampValueToBounds,
33
+ } from './slider-value-bounds';
34
+ export { splitTotalDuration, getDefaultStepDurationMs } from './duration-equal-split';
35
+ export { formatCountWithLocale } from './count-with-locale';
36
+ export { elapsedMillis } from './elapsed-millis';
@@ -0,0 +1,27 @@
1
+ const ZERO_FRACTION = 0;
2
+ const PERCENT_MULTIPLIER = 100;
3
+
4
+ function defensiveDenominator(totalSteps: number): number {
5
+ return totalSteps > 0 ? totalSteps : 1;
6
+ }
7
+
8
+ function defensiveCurrentStep(currentStep: number, totalSteps: number): number {
9
+ if (currentStep < 0) return 0;
10
+ if (currentStep > totalSteps) return totalSteps;
11
+ return currentStep;
12
+ }
13
+
14
+ export function computeProgressFraction(currentStep: number, totalSteps: number): number {
15
+ if (totalSteps <= 0) return ZERO_FRACTION;
16
+ const safeCurrent = defensiveCurrentStep(currentStep, totalSteps);
17
+ return safeCurrent / defensiveDenominator(totalSteps);
18
+ }
19
+
20
+ export function computeProgressPercentage(currentStep: number, totalSteps: number): number {
21
+ return computeProgressFraction(currentStep, totalSteps) * PERCENT_MULTIPLIER;
22
+ }
23
+
24
+ export function computeRemainingSteps(currentStep: number, totalSteps: number): number {
25
+ if (totalSteps <= 0) return 0;
26
+ return Math.max(0, totalSteps - defensiveCurrentStep(currentStep, totalSteps));
27
+ }
@@ -0,0 +1,34 @@
1
+ const SLIDER_MIN_IF_UNSET = 0;
2
+ const SLIDER_MAX_IF_UNSET = 100;
3
+
4
+ export function resolveSliderBounds(
5
+ min: number | undefined,
6
+ max: number | undefined,
7
+ ): { min: number; max: number } {
8
+ return {
9
+ min: min ?? SLIDER_MIN_IF_UNSET,
10
+ max: max ?? SLIDER_MAX_IF_UNSET,
11
+ };
12
+ }
13
+
14
+ export function areSliderBoundsValid(min: number, max: number): boolean {
15
+ return min <= max;
16
+ }
17
+
18
+ export function clampValueToBounds(
19
+ value: number,
20
+ min: number,
21
+ max: number,
22
+ preferenceId: string,
23
+ ): number {
24
+ if (!areSliderBoundsValid(min, max)) {
25
+
26
+ console.error(
27
+ `[web-onboarding] preference "${preferenceId}" has min > max (${min} > ${max}); returning value as-is.`,
28
+ );
29
+ return value;
30
+ }
31
+ if (value < min) return min;
32
+ if (value > max) return max;
33
+ return value;
34
+ }
@@ -0,0 +1,19 @@
1
+ const MIN_INDEX = 0;
2
+ const MIN_TOTAL_STEPS = 1;
3
+
4
+ export function clampStepIndex(index: number, totalSteps: number): number {
5
+ if (totalSteps < MIN_TOTAL_STEPS) return MIN_INDEX;
6
+ if (index < MIN_INDEX) return MIN_INDEX;
7
+ if (index > totalSteps - MIN_TOTAL_STEPS) return totalSteps - MIN_TOTAL_STEPS;
8
+ return index;
9
+ }
10
+
11
+ export function advanceStepIndex(currentIndex: number, totalSteps: number): number {
12
+ if (totalSteps < MIN_TOTAL_STEPS) return MIN_INDEX;
13
+ const lastIndex = totalSteps - MIN_TOTAL_STEPS;
14
+ return Math.min(currentIndex + MIN_TOTAL_STEPS, lastIndex);
15
+ }
16
+
17
+ export function retreatStepIndex(currentIndex: number): number {
18
+ return Math.max(MIN_INDEX, currentIndex - MIN_TOTAL_STEPS);
19
+ }
@@ -0,0 +1,54 @@
1
+ const SWIPE_THRESHOLD_PX = 120;
2
+ const DRAG_RANGE_PX = 200;
3
+ const OPACITY_MIDPOINT_PX = 50;
4
+ const FULL_OPACITY = 1;
5
+ const HALF_OPACITY = 0.5;
6
+ const ZERO_OPACITY = 0;
7
+ const MAX_ROTATION_DEG = 15;
8
+ const ZERO_ROTATION_DEG = 0;
9
+
10
+ export const SWIPE_DECISION_THRESHOLD_PX = SWIPE_THRESHOLD_PX;
11
+
12
+ export const SWIPE_ROTATION_RANGE: [number, number, number] = [
13
+ -DRAG_RANGE_PX,
14
+ ZERO_ROTATION_DEG,
15
+ DRAG_RANGE_PX,
16
+ ];
17
+
18
+ export const SWIPE_ROTATION_VALUES: [number, number, number] = [
19
+ -MAX_ROTATION_DEG,
20
+ ZERO_ROTATION_DEG,
21
+ MAX_ROTATION_DEG,
22
+ ];
23
+
24
+ export const SWIPE_LEFT_OPACITY_INPUT: [number, number, number] = [
25
+ -DRAG_RANGE_PX,
26
+ -OPACITY_MIDPOINT_PX,
27
+ 0,
28
+ ];
29
+
30
+ export const SWIPE_LEFT_OPACITY_VALUES: [number, number, number] = [
31
+ FULL_OPACITY,
32
+ HALF_OPACITY,
33
+ ZERO_OPACITY,
34
+ ];
35
+
36
+ export const SWIPE_RIGHT_OPACITY_INPUT: [number, number, number] = [
37
+ 0,
38
+ OPACITY_MIDPOINT_PX,
39
+ DRAG_RANGE_PX,
40
+ ];
41
+
42
+ export const SWIPE_RIGHT_OPACITY_VALUES: [number, number, number] = [
43
+ ZERO_OPACITY,
44
+ HALF_OPACITY,
45
+ FULL_OPACITY,
46
+ ];
47
+
48
+ export function didCrossRightSwipeThreshold(offsetX: number): boolean {
49
+ return offsetX > SWIPE_THRESHOLD_PX;
50
+ }
51
+
52
+ export function didCrossLeftSwipeThreshold(offsetX: number): boolean {
53
+ return offsetX < -SWIPE_THRESHOLD_PX;
54
+ }