@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.
- package/README.md +47 -73
- package/package.json +15 -7
- package/src/config/DashboardConfig.ts +19 -68
- package/src/domains/analytics/components/index.ts +0 -6
- package/src/domains/analytics/hooks/index.ts +0 -6
- package/src/domains/analytics/hooks/useAnalytics.ts +2 -20
- package/src/domains/analytics/index.ts +0 -10
- package/src/domains/analytics/services/AnalyticsEngineService.ts +7 -74
- package/src/domains/analytics/services/PerformanceService.ts +27 -138
- package/src/domains/analytics/services/index.ts +0 -4
- package/src/domains/analytics/types/analytics.ts +100 -270
- package/src/domains/analytics/types/index.ts +0 -6
- package/src/domains/analytics/utils/analytics.test.ts +2 -9
- package/src/domains/analytics/utils/analytics.ts +1 -22
- package/src/domains/analytics/utils/index.ts +0 -6
- package/src/domains/calendar/config.ts +0 -7
- package/src/domains/calendar/hooks/index.ts +0 -4
- package/src/domains/calendar/hooks/useCalendar.ts +7 -27
- package/src/domains/calendar/index.ts +0 -4
- package/src/domains/calendar/services/CalendarService.ts +0 -21
- package/src/domains/calendar/services/index.ts +0 -4
- package/src/domains/calendar/types/calendar.types.ts +9 -53
- package/src/domains/calendar/utils/contentItemMapper.test.ts +0 -4
- package/src/domains/calendar/utils/contentItemMapper.ts +0 -31
- package/src/domains/calendar/utils/index.ts +0 -26
- package/src/domains/layouts/components/index.ts +0 -4
- package/src/domains/layouts/hooks/dashboard.ts +0 -21
- package/src/domains/layouts/hooks/index.ts +0 -4
- package/src/domains/layouts/index.ts +0 -6
- package/src/domains/layouts/theme/default.ts +0 -12
- package/src/domains/layouts/theme/index.ts +0 -4
- package/src/domains/layouts/theme/presets.ts +0 -9
- package/src/domains/layouts/theme/utils.ts +0 -15
- package/src/domains/layouts/types/index.ts +0 -4
- package/src/domains/layouts/types/layout.ts +18 -56
- package/src/domains/layouts/types/notification.ts +4 -17
- package/src/domains/layouts/types/sidebar.ts +8 -28
- package/src/domains/layouts/types/theme.ts +23 -59
- package/src/domains/layouts/types/user.ts +9 -30
- package/src/domains/layouts/utils/dashboard.ts +4 -48
- package/src/domains/layouts/utils/index.ts +0 -4
- package/src/domains/modules/build.tsx +2 -16
- package/src/domains/modules/types.ts +8 -23
- package/src/domains/onboarding/calc/calc.test.ts +173 -0
- package/src/domains/onboarding/calc/card-stack-motion-curve.ts +27 -0
- package/src/domains/onboarding/calc/count-with-locale.ts +8 -0
- package/src/domains/onboarding/calc/duration-equal-split.ts +12 -0
- package/src/domains/onboarding/calc/elapsed-millis.ts +6 -0
- package/src/domains/onboarding/calc/index.ts +36 -0
- package/src/domains/onboarding/calc/progress-percentage.ts +27 -0
- package/src/domains/onboarding/calc/slider-value-bounds.ts +34 -0
- package/src/domains/onboarding/calc/step-index-clamp.ts +19 -0
- package/src/domains/onboarding/calc/swipe-gesture-curve.ts +54 -0
- package/src/domains/onboarding/components/AnimationWrapper.tsx +69 -0
- package/src/domains/onboarding/components/OnboardingErrorBoundary.tsx +61 -0
- package/src/domains/onboarding/components/OnboardingFlow.test.tsx +459 -0
- package/src/domains/onboarding/components/OnboardingFlow.tsx +313 -0
- package/src/domains/onboarding/components/OnboardingIntl.tsx +98 -0
- package/src/domains/onboarding/components/PrimaryButton.tsx +40 -0
- package/src/domains/onboarding/components/ProgressBar.tsx +32 -0
- package/src/domains/onboarding/components/QuestionWrapper.tsx +28 -0
- package/src/domains/onboarding/components/ScreenContainer.tsx +19 -0
- package/src/domains/onboarding/components/SwipeCard.test.tsx +49 -0
- package/src/domains/onboarding/components/SwipeCard.tsx +88 -0
- package/src/domains/onboarding/components/renderBundledIcon.tsx +7 -0
- package/src/domains/onboarding/components/screens/AccountGateScreen.tsx +79 -0
- package/src/domains/onboarding/components/screens/AppDemoScreen.tsx +33 -0
- package/src/domains/onboarding/components/screens/ComparisonScreen.tsx +49 -0
- package/src/domains/onboarding/components/screens/GoalQuestionScreen.tsx +59 -0
- package/src/domains/onboarding/components/screens/PainPointsScreen.tsx +65 -0
- package/src/domains/onboarding/components/screens/PaywallScreen.tsx +122 -0
- package/src/domains/onboarding/components/screens/PermissionPrimingScreen.tsx +98 -0
- package/src/domains/onboarding/components/screens/PersonalizedSolutionScreen.tsx +40 -0
- package/src/domains/onboarding/components/screens/PreferencesScreen.tsx +180 -0
- package/src/domains/onboarding/components/screens/ProcessingScreen.tsx +63 -0
- package/src/domains/onboarding/components/screens/SocialProofScreen.tsx +87 -0
- package/src/domains/onboarding/components/screens/TinderCardsScreen.tsx +91 -0
- package/src/domains/onboarding/components/screens/ValueDeliveryScreen.tsx +69 -0
- package/src/domains/onboarding/components/screens/WelcomeScreen.tsx +32 -0
- package/src/domains/onboarding/components/screens/index.ts +28 -0
- package/src/domains/onboarding/hooks/index.ts +7 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.test.tsx +74 -0
- package/src/domains/onboarding/hooks/useOnboardingAnalytics.ts +49 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.test.tsx +150 -0
- package/src/domains/onboarding/hooks/useOnboardingProgress.ts +249 -0
- package/src/domains/onboarding/hooks/usePermissionPriming.ts +45 -0
- package/src/domains/onboarding/hooks/useReducedMotion.ts +19 -0
- package/src/domains/onboarding/index.ts +153 -0
- package/src/domains/onboarding/predicates/has-handler.ts +21 -0
- package/src/domains/onboarding/predicates/icon-name-resolves.ts +97 -0
- package/src/domains/onboarding/predicates/index.ts +26 -0
- package/src/domains/onboarding/predicates/permission-granted.ts +21 -0
- package/src/domains/onboarding/predicates/permission-supports.ts +56 -0
- package/src/domains/onboarding/predicates/predicates.test.ts +188 -0
- package/src/domains/onboarding/predicates/screen-emits-data.ts +13 -0
- package/src/domains/onboarding/predicates/storage-available.ts +14 -0
- package/src/domains/onboarding/templates/TemplateDemoFallback.tsx +92 -0
- package/src/domains/onboarding/templates/art-onboarding.ts +71 -0
- package/src/domains/onboarding/templates/health-onboarding.ts +92 -0
- package/src/domains/onboarding/templates/index.ts +6 -0
- package/src/domains/onboarding/templates/platform-onboarding.ts +201 -0
- package/src/domains/onboarding/templates/video-onboarding.ts +95 -0
- package/src/domains/onboarding/test/setup.ts +23 -0
- package/src/domains/onboarding/types/config.ts +57 -0
- package/src/domains/onboarding/types/index.ts +10 -0
- package/src/domains/onboarding/types/labels.ts +49 -0
- package/src/domains/onboarding/types/screens.ts +185 -0
- package/src/domains/onboarding/utils/analytics-dispatch.test.ts +93 -0
- package/src/domains/onboarding/utils/analytics-dispatch.ts +73 -0
- package/src/domains/onboarding/utils/default-storage-key.ts +1 -0
- package/src/domains/onboarding/utils/index.ts +12 -0
- package/src/domains/onboarding/utils/local-storage-bridge.test.ts +93 -0
- package/src/domains/onboarding/utils/local-storage-bridge.ts +84 -0
- package/src/domains/onboarding/utils/permission-bridge.test.ts +64 -0
- package/src/domains/onboarding/utils/permission-bridge.ts +87 -0
- package/src/domains/onboarding/validation/flow-structure-check.ts +22 -0
- package/src/domains/onboarding/validation/index.ts +9 -0
- package/src/domains/onboarding/validation/permission-status-check.ts +18 -0
- package/src/domains/onboarding/validation/screen-config-check.ts +100 -0
- package/src/domains/onboarding/validation/stored-progress-check.ts +59 -0
- package/src/domains/onboarding/validation/validation.test.ts +170 -0
- package/src/domains/permissions/hasPermission.ts +0 -8
- package/src/domains/permissions/types.ts +0 -20
- package/src/domains/settings/components/index.ts +0 -4
- package/src/domains/settings/hooks/index.ts +0 -4
- package/src/domains/settings/hooks/useSettings.ts +1 -26
- package/src/domains/settings/index.ts +0 -6
- package/src/domains/settings/types/index.ts +0 -4
- package/src/domains/settings/types/settings.ts +23 -67
- package/src/domains/settings/utils/index.ts +0 -4
- package/src/domains/settings/utils/settings.ts +0 -45
- package/src/styles/dashboard.contract.test.ts +1 -0
- 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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
if (item.enabled === false) return false;
|
|
40
|
+
if (item.enabled === false) return false;
|
|
79
41
|
|
|
80
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,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,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
|
+
}
|