@wakastellar/ui 2.0.0 → 2.1.1

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 (291) hide show
  1. package/README.md +71 -8
  2. package/dist/cli/commands/add.d.ts +7 -0
  3. package/dist/cli/commands/init.d.ts +6 -0
  4. package/dist/cli/commands/list.d.ts +5 -0
  5. package/dist/cli/commands/search.d.ts +1 -0
  6. package/dist/cli/index.cjs +6014 -0
  7. package/dist/cli/index.d.ts +1 -0
  8. package/dist/cli/utils/config.d.ts +29 -0
  9. package/dist/cli/utils/logger.d.ts +20 -0
  10. package/dist/cli/utils/registry.d.ts +23 -0
  11. package/package.json +14 -3
  12. package/src/blocks/activity-timeline/index.tsx +586 -0
  13. package/src/blocks/calendar-view/index.tsx +756 -0
  14. package/src/blocks/chat/index.tsx +1018 -0
  15. package/src/blocks/chat/widget.tsx +504 -0
  16. package/src/blocks/dashboard/index.tsx +522 -0
  17. package/src/blocks/empty-states/index.tsx +452 -0
  18. package/src/blocks/error-pages/index.tsx +426 -0
  19. package/src/blocks/faq/index.tsx +479 -0
  20. package/src/blocks/file-manager/index.tsx +890 -0
  21. package/src/blocks/footer/index.tsx +133 -0
  22. package/src/blocks/header/index.tsx +357 -0
  23. package/src/blocks/headtab/index.tsx +139 -0
  24. package/src/blocks/i18n-editor/index.tsx +1016 -0
  25. package/src/blocks/index.ts +80 -0
  26. package/src/blocks/kanban-board/index.tsx +779 -0
  27. package/src/blocks/landing/index.tsx +677 -0
  28. package/src/blocks/language-selector/index.tsx +88 -0
  29. package/src/blocks/layout/index.tsx +159 -0
  30. package/src/blocks/login/index.tsx +339 -0
  31. package/src/blocks/login/types.ts +131 -0
  32. package/src/blocks/pricing/index.tsx +564 -0
  33. package/src/blocks/profile/index.tsx +746 -0
  34. package/src/blocks/settings/index.tsx +558 -0
  35. package/src/blocks/sidebar/index.tsx +713 -0
  36. package/src/blocks/theme-creator-block/index.tsx +835 -0
  37. package/src/blocks/user-management/index.tsx +1037 -0
  38. package/src/blocks/wizard/index.tsx +719 -0
  39. package/src/components/DataTable/DataTable.tsx +406 -0
  40. package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
  41. package/src/components/DataTable/DataTableBody.tsx +216 -0
  42. package/src/components/DataTable/DataTableCell.tsx +172 -0
  43. package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
  44. package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
  45. package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
  46. package/src/components/DataTable/DataTableEditCell.tsx +279 -0
  47. package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
  48. package/src/components/DataTable/DataTableFilters.tsx +535 -0
  49. package/src/components/DataTable/DataTableGrouping.tsx +147 -0
  50. package/src/components/DataTable/DataTableHeader.tsx +172 -0
  51. package/src/components/DataTable/DataTablePagination.tsx +125 -0
  52. package/src/components/DataTable/DataTableSelection.tsx +269 -0
  53. package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
  54. package/src/components/DataTable/DataTableToolbar.tsx +262 -0
  55. package/src/components/DataTable/README.md +446 -0
  56. package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
  57. package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
  58. package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
  59. package/src/components/DataTable/examples/EditExample.tsx +166 -0
  60. package/src/components/DataTable/formatters/index.ts +335 -0
  61. package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
  62. package/src/components/DataTable/hooks/useDataTable.ts +145 -0
  63. package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
  64. package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
  65. package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
  66. package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
  67. package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
  68. package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
  69. package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
  70. package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
  71. package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
  72. package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
  73. package/src/components/DataTable/index.ts +81 -0
  74. package/src/components/DataTable/services/IndexedDBService.ts +504 -0
  75. package/src/components/DataTable/templates/index.tsx +803 -0
  76. package/src/components/DataTable/types.ts +504 -0
  77. package/src/components/DataTable/utils.ts +164 -0
  78. package/src/components/DataTable/workers/exportWorker.ts +213 -0
  79. package/src/components/accordion/index.tsx +61 -0
  80. package/src/components/alert/index.tsx +61 -0
  81. package/src/components/alert-dialog/index.tsx +146 -0
  82. package/src/components/aspect-ratio/index.tsx +12 -0
  83. package/src/components/avatar/index.tsx +54 -0
  84. package/src/components/badge/Badge.stories.tsx +64 -0
  85. package/src/components/badge/index.tsx +38 -0
  86. package/src/components/button/Button.stories.tsx +173 -0
  87. package/src/components/button/index.tsx +56 -0
  88. package/src/components/calendar/index.tsx +73 -0
  89. package/src/components/card/index.tsx +78 -0
  90. package/src/components/checkbox/index.tsx +34 -0
  91. package/src/components/code/index.tsx +229 -0
  92. package/src/components/collapsible/index.tsx +16 -0
  93. package/src/components/command/index.tsx +162 -0
  94. package/src/components/context-menu/index.tsx +204 -0
  95. package/src/components/dialog/index.tsx +126 -0
  96. package/src/components/dropdown-menu/index.tsx +204 -0
  97. package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
  98. package/src/components/error-boundary/index.ts +7 -0
  99. package/src/components/form/index.tsx +183 -0
  100. package/src/components/hover-card/index.tsx +33 -0
  101. package/src/components/index.ts +368 -0
  102. package/src/components/input/Input.stories.tsx +100 -0
  103. package/src/components/input/index.tsx +27 -0
  104. package/src/components/input-otp/index.tsx +277 -0
  105. package/src/components/label/index.tsx +30 -0
  106. package/src/components/language-selector/index.tsx +341 -0
  107. package/src/components/menubar/index.tsx +240 -0
  108. package/src/components/navigation-menu/index.tsx +134 -0
  109. package/src/components/popover/index.tsx +35 -0
  110. package/src/components/progress/index.tsx +32 -0
  111. package/src/components/radio-group/index.tsx +48 -0
  112. package/src/components/scroll-area/index.tsx +52 -0
  113. package/src/components/select/index.tsx +164 -0
  114. package/src/components/separator/index.tsx +35 -0
  115. package/src/components/sheet/index.tsx +147 -0
  116. package/src/components/skeleton/index.tsx +22 -0
  117. package/src/components/slider/index.tsx +32 -0
  118. package/src/components/switch/index.tsx +33 -0
  119. package/src/components/table/index.tsx +117 -0
  120. package/src/components/tabs/index.tsx +59 -0
  121. package/src/components/textarea/index.tsx +30 -0
  122. package/src/components/theme-selector/index.tsx +327 -0
  123. package/src/components/toast/index.tsx +133 -0
  124. package/src/components/toaster/index.tsx +34 -0
  125. package/src/components/toggle/index.tsx +49 -0
  126. package/src/components/tooltip/index.tsx +34 -0
  127. package/src/components/typography/index.tsx +276 -0
  128. package/src/components/waka-3d-pie-chart/index.tsx +486 -0
  129. package/src/components/waka-achievement-unlock/index.tsx +716 -0
  130. package/src/components/waka-activity-feed/index.tsx +686 -0
  131. package/src/components/waka-address-autocomplete/index.tsx +1202 -0
  132. package/src/components/waka-admincrumb/index.tsx +349 -0
  133. package/src/components/waka-alert-stack/index.tsx +827 -0
  134. package/src/components/waka-allocation-matrix/index.tsx +1278 -0
  135. package/src/components/waka-approval-chain/index.tsx +766 -0
  136. package/src/components/waka-audit-log/index.tsx +1475 -0
  137. package/src/components/waka-autocomplete/index.tsx +358 -0
  138. package/src/components/waka-badge-showcase/index.tsx +704 -0
  139. package/src/components/waka-barcode/index.tsx +260 -0
  140. package/src/components/waka-biometric-prompt/index.tsx +765 -0
  141. package/src/components/waka-bottom-sheet/index.tsx +495 -0
  142. package/src/components/waka-breadcrumb/index.tsx +376 -0
  143. package/src/components/waka-breadcrumb-path/index.tsx +513 -0
  144. package/src/components/waka-budget-burn/index.tsx +1234 -0
  145. package/src/components/waka-capacity-planner/index.tsx +1107 -0
  146. package/src/components/waka-carousel/index.tsx +893 -0
  147. package/src/components/waka-cart-summary/index.tsx +1055 -0
  148. package/src/components/waka-challenge-timer/index.tsx +1044 -0
  149. package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
  150. package/src/components/waka-charts/WakaBarChart.tsx +222 -0
  151. package/src/components/waka-charts/WakaChart.tsx +124 -0
  152. package/src/components/waka-charts/WakaLineChart.tsx +219 -0
  153. package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
  154. package/src/components/waka-charts/WakaPieChart.tsx +214 -0
  155. package/src/components/waka-charts/WakaSparkline.tsx +229 -0
  156. package/src/components/waka-charts/dataTableHelpers.ts +109 -0
  157. package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
  158. package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
  159. package/src/components/waka-charts/index.ts +90 -0
  160. package/src/components/waka-charts/types.ts +330 -0
  161. package/src/components/waka-chat-bubble/index.tsx +1060 -0
  162. package/src/components/waka-checklist/index.tsx +1067 -0
  163. package/src/components/waka-checkout-stepper/index.tsx +976 -0
  164. package/src/components/waka-cohort-table/index.tsx +1011 -0
  165. package/src/components/waka-color-picker/index.tsx +447 -0
  166. package/src/components/waka-combo-counter/index.tsx +864 -0
  167. package/src/components/waka-combobox/index.tsx +497 -0
  168. package/src/components/waka-command-bar/index.tsx +403 -0
  169. package/src/components/waka-compare-period/index.tsx +1230 -0
  170. package/src/components/waka-connection-matrix/index.tsx +1053 -0
  171. package/src/components/waka-contribution-graph/index.tsx +552 -0
  172. package/src/components/waka-cost-breakdown/index.tsx +1065 -0
  173. package/src/components/waka-coupon-input/index.tsx +592 -0
  174. package/src/components/waka-credit-card-input/index.tsx +982 -0
  175. package/src/components/waka-daily-reward/index.tsx +762 -0
  176. package/src/components/waka-date-range-picker/index.tsx +378 -0
  177. package/src/components/waka-datetime-picker/index.tsx +793 -0
  178. package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
  179. package/src/components/waka-deployment-lane/index.tsx +673 -0
  180. package/src/components/waka-device-trust/index.tsx +1259 -0
  181. package/src/components/waka-dock/index.tsx +285 -0
  182. package/src/components/waka-drawer/index.tsx +319 -0
  183. package/src/components/waka-empty-state/index.tsx +545 -0
  184. package/src/components/waka-error-shake/index.tsx +398 -0
  185. package/src/components/waka-feature-announcement/index.tsx +991 -0
  186. package/src/components/waka-file-upload/index.tsx +437 -0
  187. package/src/components/waka-floating-nav/index.tsx +413 -0
  188. package/src/components/waka-flow-diagram/index.tsx +508 -0
  189. package/src/components/waka-funnel-chart/index.tsx +823 -0
  190. package/src/components/waka-glow-card/index.tsx +246 -0
  191. package/src/components/waka-goal-progress/index.tsx +1025 -0
  192. package/src/components/waka-haptic-button/index.tsx +388 -0
  193. package/src/components/waka-health-pulse/index.tsx +451 -0
  194. package/src/components/waka-heatmap/index.tsx +1026 -0
  195. package/src/components/waka-hotspot/index.tsx +682 -0
  196. package/src/components/waka-image/index.tsx +373 -0
  197. package/src/components/waka-incident-timeline/index.tsx +686 -0
  198. package/src/components/waka-invoice-preview/index.tsx +829 -0
  199. package/src/components/waka-kanban/index.tsx +646 -0
  200. package/src/components/waka-kpi-dashboard/index.tsx +755 -0
  201. package/src/components/waka-leaderboard/index.tsx +746 -0
  202. package/src/components/waka-level-progress/index.tsx +665 -0
  203. package/src/components/waka-liquid-button/index.tsx +520 -0
  204. package/src/components/waka-loading-orbit/index.tsx +478 -0
  205. package/src/components/waka-loot-box/index.tsx +1091 -0
  206. package/src/components/waka-magic-link/index.tsx +321 -0
  207. package/src/components/waka-magnetic-button/index.tsx +567 -0
  208. package/src/components/waka-mention-input/index.tsx +953 -0
  209. package/src/components/waka-metric-sparkline/index.tsx +627 -0
  210. package/src/components/waka-milestone-road/index.tsx +1064 -0
  211. package/src/components/waka-modal/index.tsx +374 -0
  212. package/src/components/waka-morph-button/index.tsx +495 -0
  213. package/src/components/waka-network-topology/index.tsx +801 -0
  214. package/src/components/waka-notifications/index.tsx +414 -0
  215. package/src/components/waka-number-input/index.tsx +373 -0
  216. package/src/components/waka-orbital-menu/index.tsx +445 -0
  217. package/src/components/waka-order-tracker/index.tsx +1041 -0
  218. package/src/components/waka-pagination/index.tsx +393 -0
  219. package/src/components/waka-password-strength/index.tsx +824 -0
  220. package/src/components/waka-payment-method-picker/index.tsx +715 -0
  221. package/src/components/waka-permission-matrix/index.tsx +1302 -0
  222. package/src/components/waka-phone-input/index.tsx +801 -0
  223. package/src/components/waka-pipeline-view/index.tsx +604 -0
  224. package/src/components/waka-player-card/index.tsx +691 -0
  225. package/src/components/waka-points-popup/index.tsx +366 -0
  226. package/src/components/waka-power-up/index.tsx +1155 -0
  227. package/src/components/waka-presence-indicator/index.tsx +1181 -0
  228. package/src/components/waka-pricing-table/index.tsx +755 -0
  229. package/src/components/waka-product-card/index.tsx +786 -0
  230. package/src/components/waka-progress-onboarding/index.tsx +878 -0
  231. package/src/components/waka-pull-to-refresh/index.tsx +451 -0
  232. package/src/components/waka-qrcode/index.tsx +232 -0
  233. package/src/components/waka-quest-card/index.tsx +1275 -0
  234. package/src/components/waka-quota-bar/index.tsx +693 -0
  235. package/src/components/waka-radar-score/index.tsx +512 -0
  236. package/src/components/waka-rank-badge/index.tsx +813 -0
  237. package/src/components/waka-rating-input/index.tsx +560 -0
  238. package/src/components/waka-reaction-picker/index.tsx +1062 -0
  239. package/src/components/waka-region-map/index.tsx +730 -0
  240. package/src/components/waka-resource-gauge/index.tsx +654 -0
  241. package/src/components/waka-resource-pool/index.tsx +1035 -0
  242. package/src/components/waka-rich-text-editor/index.tsx +594 -0
  243. package/src/components/waka-rollback-slider/index.tsx +891 -0
  244. package/src/components/waka-sankey-diagram/index.tsx +1032 -0
  245. package/src/components/waka-schedule-picker/index.tsx +1060 -0
  246. package/src/components/waka-scratch-card/index.tsx +914 -0
  247. package/src/components/waka-season-pass/index.tsx +886 -0
  248. package/src/components/waka-security-score/index.tsx +1126 -0
  249. package/src/components/waka-segmented-control/index.tsx +238 -0
  250. package/src/components/waka-server-rack/index.tsx +764 -0
  251. package/src/components/waka-session-manager/index.tsx +815 -0
  252. package/src/components/waka-signature-pad/index.tsx +744 -0
  253. package/src/components/waka-skeleton-wave/index.tsx +454 -0
  254. package/src/components/waka-skill-tree/index.tsx +1031 -0
  255. package/src/components/waka-sla-tracker/index.tsx +798 -0
  256. package/src/components/waka-slider-range/index.tsx +765 -0
  257. package/src/components/waka-spin-wheel/index.tsx +671 -0
  258. package/src/components/waka-spinner/index.tsx +284 -0
  259. package/src/components/waka-spotlight/index.tsx +410 -0
  260. package/src/components/waka-stat/index.tsx +428 -0
  261. package/src/components/waka-stats-hexagon/index.tsx +824 -0
  262. package/src/components/waka-status-matrix/index.tsx +565 -0
  263. package/src/components/waka-stepper/index.tsx +489 -0
  264. package/src/components/waka-streak-counter/index.tsx +334 -0
  265. package/src/components/waka-success-explosion/index.tsx +453 -0
  266. package/src/components/waka-swipe-card/index.tsx +574 -0
  267. package/src/components/waka-tabs-morph/index.tsx +509 -0
  268. package/src/components/waka-tag-input/index.tsx +877 -0
  269. package/src/components/waka-team-banner/index.tsx +1183 -0
  270. package/src/components/waka-terminal-output/index.tsx +836 -0
  271. package/src/components/waka-theme-creator/index.tsx +762 -0
  272. package/src/components/waka-theme-manager/index.tsx +654 -0
  273. package/src/components/waka-thread-view/index.tsx +874 -0
  274. package/src/components/waka-tilt-card/index.tsx +250 -0
  275. package/src/components/waka-time-picker/index.tsx +479 -0
  276. package/src/components/waka-timeline/index.tsx +385 -0
  277. package/src/components/waka-tooltip-tour/index.tsx +855 -0
  278. package/src/components/waka-tour-guide/index.tsx +920 -0
  279. package/src/components/waka-tournament-bracket/index.tsx +1276 -0
  280. package/src/components/waka-tree/index.tsx +557 -0
  281. package/src/components/waka-treemap-chart/index.tsx +1031 -0
  282. package/src/components/waka-two-factor-setup/index.tsx +995 -0
  283. package/src/components/waka-typewriter/index.tsx +566 -0
  284. package/src/components/waka-typing-indicator/index.tsx +649 -0
  285. package/src/components/waka-versus-card/index.tsx +1026 -0
  286. package/src/components/waka-video/index.tsx +557 -0
  287. package/src/components/waka-video-call/index.tsx +1087 -0
  288. package/src/components/waka-virtual-list/index.tsx +327 -0
  289. package/src/components/waka-voice-message/index.tsx +1019 -0
  290. package/src/components/waka-welcome-modal/index.tsx +790 -0
  291. package/src/components/waka-xp-bar/index.tsx +799 -0
@@ -0,0 +1,1230 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils/cn"
5
+ import {
6
+ TrendingUp,
7
+ TrendingDown,
8
+ Minus,
9
+ Calendar,
10
+ ArrowRight,
11
+ ChevronDown,
12
+ BarChart3,
13
+ RefreshCw,
14
+ Check,
15
+ } from "lucide-react"
16
+
17
+ // ============================================================================
18
+ // Types
19
+ // ============================================================================
20
+
21
+ export type PeriodPreset = "last_week" | "last_month" | "last_quarter" | "last_year" | "custom"
22
+
23
+ export type TrendDirection = "up" | "down" | "neutral"
24
+
25
+ export interface PeriodRange {
26
+ /** Start date of the period */
27
+ start: Date
28
+ /** End date of the period */
29
+ end: Date
30
+ /** Optional label for display */
31
+ label?: string
32
+ }
33
+
34
+ export interface MetricValue {
35
+ /** Current period value */
36
+ current: number
37
+ /** Previous period value */
38
+ previous: number
39
+ }
40
+
41
+ export interface Metric {
42
+ /** Unique identifier for the metric */
43
+ id: string
44
+ /** Display name */
45
+ name: string
46
+ /** Metric values for current and previous periods */
47
+ value: MetricValue
48
+ /** Format function for displaying the value */
49
+ format?: (value: number) => string
50
+ /** Unit suffix (e.g., "%", "k", "ms") */
51
+ unit?: string
52
+ /** Invert trend colors (lower is better) */
53
+ invertTrend?: boolean
54
+ /** Custom icon */
55
+ icon?: React.ReactNode
56
+ /** Color theme for the metric */
57
+ color?: "default" | "blue" | "green" | "purple" | "orange" | "red"
58
+ }
59
+
60
+ export interface ComparePeriodSummary {
61
+ /** Total metrics improving */
62
+ improving: number
63
+ /** Total metrics declining */
64
+ declining: number
65
+ /** Total metrics unchanged */
66
+ unchanged: number
67
+ /** Average percentage change */
68
+ averageChange: number
69
+ /** Best performing metric ID */
70
+ bestMetricId?: string
71
+ /** Worst performing metric ID */
72
+ worstMetricId?: string
73
+ }
74
+
75
+ export interface WakaComparePeriodProps {
76
+ /** Array of metrics to compare */
77
+ metrics: Metric[]
78
+ /** Current period range */
79
+ currentPeriod: PeriodRange
80
+ /** Previous period range */
81
+ previousPeriod: PeriodRange
82
+ /** Selected period preset */
83
+ periodPreset?: PeriodPreset
84
+ /** Callback when period preset changes */
85
+ onPeriodChange?: (preset: PeriodPreset, current: PeriodRange, previous: PeriodRange) => void
86
+ /** Show period selector dropdown */
87
+ showPeriodSelector?: boolean
88
+ /** Show visual comparison bars */
89
+ showComparisonBars?: boolean
90
+ /** Show summary statistics */
91
+ showSummary?: boolean
92
+ /** Enable animated value transitions */
93
+ animated?: boolean
94
+ /** Layout variant */
95
+ variant?: "grid" | "list" | "compact"
96
+ /** Number of columns for grid layout */
97
+ columns?: 2 | 3 | 4
98
+ /** Additional CSS classes */
99
+ className?: string
100
+ }
101
+
102
+ // ============================================================================
103
+ // Configuration
104
+ // ============================================================================
105
+
106
+ const colorConfig = {
107
+ default: {
108
+ bar: "bg-primary",
109
+ barBg: "bg-primary/20",
110
+ text: "text-primary",
111
+ icon: "text-primary",
112
+ },
113
+ blue: {
114
+ bar: "bg-blue-500",
115
+ barBg: "bg-blue-500/20",
116
+ text: "text-blue-600",
117
+ icon: "text-blue-500",
118
+ },
119
+ green: {
120
+ bar: "bg-green-500",
121
+ barBg: "bg-green-500/20",
122
+ text: "text-green-600",
123
+ icon: "text-green-500",
124
+ },
125
+ purple: {
126
+ bar: "bg-purple-500",
127
+ barBg: "bg-purple-500/20",
128
+ text: "text-purple-600",
129
+ icon: "text-purple-500",
130
+ },
131
+ orange: {
132
+ bar: "bg-orange-500",
133
+ barBg: "bg-orange-500/20",
134
+ text: "text-orange-600",
135
+ icon: "text-orange-500",
136
+ },
137
+ red: {
138
+ bar: "bg-red-500",
139
+ barBg: "bg-red-500/20",
140
+ text: "text-red-600",
141
+ icon: "text-red-500",
142
+ },
143
+ }
144
+
145
+ const periodPresetConfig: Record<PeriodPreset, { label: string; shortLabel: string }> = {
146
+ last_week: { label: "vs Last Week", shortLabel: "Week" },
147
+ last_month: { label: "vs Last Month", shortLabel: "Month" },
148
+ last_quarter: { label: "vs Last Quarter", shortLabel: "Quarter" },
149
+ last_year: { label: "vs Last Year", shortLabel: "Year" },
150
+ custom: { label: "Custom Range", shortLabel: "Custom" },
151
+ }
152
+
153
+ // ============================================================================
154
+ // Utility Functions
155
+ // ============================================================================
156
+
157
+ function calculateChange(current: number, previous: number): {
158
+ percentage: number
159
+ absolute: number
160
+ direction: TrendDirection
161
+ } {
162
+ if (previous === 0) {
163
+ return {
164
+ percentage: current > 0 ? 100 : 0,
165
+ absolute: current,
166
+ direction: current > 0 ? "up" : current < 0 ? "down" : "neutral",
167
+ }
168
+ }
169
+
170
+ const absolute = current - previous
171
+ const percentage = ((current - previous) / Math.abs(previous)) * 100
172
+
173
+ let direction: TrendDirection = "neutral"
174
+ if (percentage > 0.01) direction = "up"
175
+ else if (percentage < -0.01) direction = "down"
176
+
177
+ return { percentage, absolute, direction }
178
+ }
179
+
180
+ function formatPercentage(value: number): string {
181
+ const sign = value >= 0 ? "+" : ""
182
+ return `${sign}${value.toFixed(1)}%`
183
+ }
184
+
185
+ function formatAbsolute(value: number, format?: (v: number) => string, unit?: string): string {
186
+ const sign = value >= 0 ? "+" : ""
187
+ const formatted = format ? format(Math.abs(value)) : Math.abs(value).toLocaleString()
188
+ return `${sign}${value < 0 ? "-" : ""}${formatted}${unit || ""}`
189
+ }
190
+
191
+ function formatDate(date: Date): string {
192
+ return date.toLocaleDateString("en-US", {
193
+ month: "short",
194
+ day: "numeric",
195
+ })
196
+ }
197
+
198
+ function calculatePeriodRanges(
199
+ preset: PeriodPreset,
200
+ referenceDate: Date = new Date()
201
+ ): { current: PeriodRange; previous: PeriodRange } {
202
+ const now = new Date(referenceDate)
203
+ now.setHours(23, 59, 59, 999)
204
+
205
+ let currentStart: Date
206
+ let currentEnd: Date = now
207
+ let previousStart: Date
208
+ let previousEnd: Date
209
+
210
+ switch (preset) {
211
+ case "last_week": {
212
+ currentStart = new Date(now)
213
+ currentStart.setDate(now.getDate() - 6)
214
+ currentStart.setHours(0, 0, 0, 0)
215
+
216
+ previousEnd = new Date(currentStart)
217
+ previousEnd.setDate(previousEnd.getDate() - 1)
218
+ previousEnd.setHours(23, 59, 59, 999)
219
+
220
+ previousStart = new Date(previousEnd)
221
+ previousStart.setDate(previousEnd.getDate() - 6)
222
+ previousStart.setHours(0, 0, 0, 0)
223
+ break
224
+ }
225
+ case "last_month": {
226
+ currentStart = new Date(now)
227
+ currentStart.setDate(now.getDate() - 29)
228
+ currentStart.setHours(0, 0, 0, 0)
229
+
230
+ previousEnd = new Date(currentStart)
231
+ previousEnd.setDate(previousEnd.getDate() - 1)
232
+ previousEnd.setHours(23, 59, 59, 999)
233
+
234
+ previousStart = new Date(previousEnd)
235
+ previousStart.setDate(previousEnd.getDate() - 29)
236
+ previousStart.setHours(0, 0, 0, 0)
237
+ break
238
+ }
239
+ case "last_quarter": {
240
+ currentStart = new Date(now)
241
+ currentStart.setDate(now.getDate() - 89)
242
+ currentStart.setHours(0, 0, 0, 0)
243
+
244
+ previousEnd = new Date(currentStart)
245
+ previousEnd.setDate(previousEnd.getDate() - 1)
246
+ previousEnd.setHours(23, 59, 59, 999)
247
+
248
+ previousStart = new Date(previousEnd)
249
+ previousStart.setDate(previousEnd.getDate() - 89)
250
+ previousStart.setHours(0, 0, 0, 0)
251
+ break
252
+ }
253
+ case "last_year": {
254
+ currentStart = new Date(now)
255
+ currentStart.setFullYear(now.getFullYear() - 1)
256
+ currentStart.setHours(0, 0, 0, 0)
257
+
258
+ previousEnd = new Date(currentStart)
259
+ previousEnd.setDate(previousEnd.getDate() - 1)
260
+ previousEnd.setHours(23, 59, 59, 999)
261
+
262
+ previousStart = new Date(previousEnd)
263
+ previousStart.setFullYear(previousEnd.getFullYear() - 1)
264
+ previousStart.setHours(0, 0, 0, 0)
265
+ break
266
+ }
267
+ default: {
268
+ // Custom - return current week as default
269
+ currentStart = new Date(now)
270
+ currentStart.setDate(now.getDate() - 6)
271
+ currentStart.setHours(0, 0, 0, 0)
272
+
273
+ previousEnd = new Date(currentStart)
274
+ previousEnd.setDate(previousEnd.getDate() - 1)
275
+ previousEnd.setHours(23, 59, 59, 999)
276
+
277
+ previousStart = new Date(previousEnd)
278
+ previousStart.setDate(previousEnd.getDate() - 6)
279
+ previousStart.setHours(0, 0, 0, 0)
280
+ }
281
+ }
282
+
283
+ return {
284
+ current: {
285
+ start: currentStart,
286
+ end: currentEnd,
287
+ label: `${formatDate(currentStart)} - ${formatDate(currentEnd)}`,
288
+ },
289
+ previous: {
290
+ start: previousStart,
291
+ end: previousEnd,
292
+ label: `${formatDate(previousStart)} - ${formatDate(previousEnd)}`,
293
+ },
294
+ }
295
+ }
296
+
297
+ // ============================================================================
298
+ // Trend Arrow Component
299
+ // ============================================================================
300
+
301
+ interface TrendArrowProps {
302
+ direction: TrendDirection
303
+ invert?: boolean
304
+ size?: "sm" | "md" | "lg"
305
+ animated?: boolean
306
+ }
307
+
308
+ function TrendArrow({ direction, invert = false, size = "md", animated = true }: TrendArrowProps) {
309
+ const isPositive = invert ? direction === "down" : direction === "up"
310
+ const isNegative = invert ? direction === "up" : direction === "down"
311
+
312
+ const sizeClasses = {
313
+ sm: "h-3 w-3",
314
+ md: "h-4 w-4",
315
+ lg: "h-5 w-5",
316
+ }
317
+
318
+ const Icon =
319
+ direction === "up"
320
+ ? TrendingUp
321
+ : direction === "down"
322
+ ? TrendingDown
323
+ : Minus
324
+
325
+ return (
326
+ <div
327
+ className={cn(
328
+ "flex items-center justify-center transition-transform duration-300",
329
+ isPositive && "text-green-600",
330
+ isNegative && "text-red-600",
331
+ direction === "neutral" && "text-muted-foreground",
332
+ animated && direction === "up" && "animate-trend-up",
333
+ animated && direction === "down" && "animate-trend-down"
334
+ )}
335
+ >
336
+ <Icon className={sizeClasses[size]} />
337
+ <style>{`
338
+ @keyframes trend-up {
339
+ 0%, 100% { transform: translateY(0); }
340
+ 50% { transform: translateY(-2px); }
341
+ }
342
+ @keyframes trend-down {
343
+ 0%, 100% { transform: translateY(0); }
344
+ 50% { transform: translateY(2px); }
345
+ }
346
+ .animate-trend-up {
347
+ animation: trend-up 2s ease-in-out infinite;
348
+ }
349
+ .animate-trend-down {
350
+ animation: trend-down 2s ease-in-out infinite;
351
+ }
352
+ `}</style>
353
+ </div>
354
+ )
355
+ }
356
+
357
+ // ============================================================================
358
+ // Animated Value Component
359
+ // ============================================================================
360
+
361
+ interface AnimatedValueProps {
362
+ value: number
363
+ format?: (value: number) => string
364
+ unit?: string
365
+ className?: string
366
+ animated?: boolean
367
+ }
368
+
369
+ function AnimatedValue({ value, format, unit, className, animated = true }: AnimatedValueProps) {
370
+ const [displayValue, setDisplayValue] = React.useState(value)
371
+ const prevValueRef = React.useRef(value)
372
+
373
+ React.useEffect(() => {
374
+ if (!animated || value === prevValueRef.current) {
375
+ setDisplayValue(value)
376
+ prevValueRef.current = value
377
+ return
378
+ }
379
+
380
+ const startValue = prevValueRef.current
381
+ const endValue = value
382
+ const duration = 500
383
+ const startTime = performance.now()
384
+
385
+ const animate = (currentTime: number) => {
386
+ const elapsed = currentTime - startTime
387
+ const progress = Math.min(elapsed / duration, 1)
388
+ const eased = 1 - Math.pow(1 - progress, 3)
389
+ const currentValue = startValue + (endValue - startValue) * eased
390
+
391
+ setDisplayValue(currentValue)
392
+
393
+ if (progress < 1) {
394
+ requestAnimationFrame(animate)
395
+ }
396
+ }
397
+
398
+ requestAnimationFrame(animate)
399
+ prevValueRef.current = value
400
+ }, [value, animated])
401
+
402
+ const formatted = format
403
+ ? format(displayValue)
404
+ : Number.isInteger(displayValue)
405
+ ? displayValue.toLocaleString()
406
+ : displayValue.toLocaleString(undefined, { maximumFractionDigits: 2 })
407
+
408
+ return (
409
+ <span className={cn("tabular-nums", className)}>
410
+ {formatted}
411
+ {unit && <span className="text-muted-foreground ml-0.5">{unit}</span>}
412
+ </span>
413
+ )
414
+ }
415
+
416
+ // ============================================================================
417
+ // Comparison Bar Component
418
+ // ============================================================================
419
+
420
+ interface ComparisonBarProps {
421
+ current: number
422
+ previous: number
423
+ color?: keyof typeof colorConfig
424
+ animated?: boolean
425
+ }
426
+
427
+ function ComparisonBar({
428
+ current,
429
+ previous,
430
+ color = "default",
431
+ animated = true,
432
+ }: ComparisonBarProps) {
433
+ const [isVisible, setIsVisible] = React.useState(!animated)
434
+ const maxValue = Math.max(current, previous, 1)
435
+ const currentPercent = (current / maxValue) * 100
436
+ const previousPercent = (previous / maxValue) * 100
437
+
438
+ React.useEffect(() => {
439
+ if (animated) {
440
+ const timer = setTimeout(() => setIsVisible(true), 100)
441
+ return () => clearTimeout(timer)
442
+ }
443
+ }, [animated])
444
+
445
+ const colors = colorConfig[color]
446
+
447
+ return (
448
+ <div className="space-y-1.5">
449
+ {/* Current period bar */}
450
+ <div className="flex items-center gap-2">
451
+ <span className="text-[10px] text-muted-foreground w-12">Current</span>
452
+ <div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
453
+ <div
454
+ className={cn(
455
+ "h-full rounded-full transition-all duration-700 ease-out",
456
+ colors.bar
457
+ )}
458
+ style={{ width: isVisible ? `${currentPercent}%` : "0%" }}
459
+ />
460
+ </div>
461
+ </div>
462
+
463
+ {/* Previous period bar */}
464
+ <div className="flex items-center gap-2">
465
+ <span className="text-[10px] text-muted-foreground w-12">Previous</span>
466
+ <div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
467
+ <div
468
+ className={cn(
469
+ "h-full rounded-full transition-all duration-700 ease-out opacity-50",
470
+ colors.bar
471
+ )}
472
+ style={{ width: isVisible ? `${previousPercent}%` : "0%" }}
473
+ />
474
+ </div>
475
+ </div>
476
+ </div>
477
+ )
478
+ }
479
+
480
+ // ============================================================================
481
+ // Period Selector Component
482
+ // ============================================================================
483
+
484
+ interface PeriodSelectorProps {
485
+ selectedPreset: PeriodPreset
486
+ onSelect: (preset: PeriodPreset) => void
487
+ currentPeriod: PeriodRange
488
+ previousPeriod: PeriodRange
489
+ className?: string
490
+ }
491
+
492
+ function PeriodSelector({
493
+ selectedPreset,
494
+ onSelect,
495
+ currentPeriod,
496
+ previousPeriod,
497
+ className,
498
+ }: PeriodSelectorProps) {
499
+ const [isOpen, setIsOpen] = React.useState(false)
500
+ const containerRef = React.useRef<HTMLDivElement>(null)
501
+
502
+ // Close dropdown when clicking outside
503
+ React.useEffect(() => {
504
+ function handleClickOutside(event: MouseEvent) {
505
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
506
+ setIsOpen(false)
507
+ }
508
+ }
509
+
510
+ document.addEventListener("mousedown", handleClickOutside)
511
+ return () => document.removeEventListener("mousedown", handleClickOutside)
512
+ }, [])
513
+
514
+ const presets: PeriodPreset[] = ["last_week", "last_month", "last_quarter", "last_year", "custom"]
515
+
516
+ return (
517
+ <div ref={containerRef} className={cn("relative", className)}>
518
+ {/* Trigger button */}
519
+ <button
520
+ type="button"
521
+ onClick={() => setIsOpen(!isOpen)}
522
+ className={cn(
523
+ "flex items-center gap-2 px-3 py-2 rounded-lg border bg-card",
524
+ "text-sm font-medium transition-colors",
525
+ "hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20",
526
+ isOpen && "ring-2 ring-primary/20"
527
+ )}
528
+ >
529
+ <Calendar className="h-4 w-4 text-muted-foreground" />
530
+ <span>{periodPresetConfig[selectedPreset].label}</span>
531
+ <ChevronDown
532
+ className={cn(
533
+ "h-4 w-4 text-muted-foreground transition-transform duration-200",
534
+ isOpen && "rotate-180"
535
+ )}
536
+ />
537
+ </button>
538
+
539
+ {/* Dropdown menu */}
540
+ {isOpen && (
541
+ <div
542
+ className={cn(
543
+ "absolute top-full right-0 mt-2 z-50 min-w-[280px]",
544
+ "rounded-lg border bg-card shadow-lg",
545
+ "animate-in fade-in-0 zoom-in-95 duration-100"
546
+ )}
547
+ >
548
+ {/* Period range display */}
549
+ <div className="p-3 border-b">
550
+ <div className="flex items-center gap-2 text-sm">
551
+ <div className="flex-1 text-center">
552
+ <div className="text-[10px] uppercase text-muted-foreground mb-1">Current</div>
553
+ <div className="font-medium">{currentPeriod.label}</div>
554
+ </div>
555
+ <ArrowRight className="h-4 w-4 text-muted-foreground" />
556
+ <div className="flex-1 text-center">
557
+ <div className="text-[10px] uppercase text-muted-foreground mb-1">Previous</div>
558
+ <div className="font-medium">{previousPeriod.label}</div>
559
+ </div>
560
+ </div>
561
+ </div>
562
+
563
+ {/* Preset options */}
564
+ <div className="p-1">
565
+ {presets.map((preset) => (
566
+ <button
567
+ key={preset}
568
+ type="button"
569
+ onClick={() => {
570
+ onSelect(preset)
571
+ setIsOpen(false)
572
+ }}
573
+ className={cn(
574
+ "flex items-center justify-between w-full px-3 py-2 rounded-md text-sm",
575
+ "transition-colors hover:bg-muted",
576
+ selectedPreset === preset && "bg-primary/10 text-primary"
577
+ )}
578
+ >
579
+ <span>{periodPresetConfig[preset].label}</span>
580
+ {selectedPreset === preset && <Check className="h-4 w-4" />}
581
+ </button>
582
+ ))}
583
+ </div>
584
+ </div>
585
+ )}
586
+ </div>
587
+ )
588
+ }
589
+
590
+ // ============================================================================
591
+ // Metric Card Component
592
+ // ============================================================================
593
+
594
+ interface MetricCardProps {
595
+ metric: Metric
596
+ showComparisonBars: boolean
597
+ animated: boolean
598
+ variant: "grid" | "list" | "compact"
599
+ }
600
+
601
+ function MetricCard({ metric, showComparisonBars, animated, variant }: MetricCardProps) {
602
+ const { percentage, absolute, direction } = calculateChange(
603
+ metric.value.current,
604
+ metric.value.previous
605
+ )
606
+ const colors = colorConfig[metric.color || "default"]
607
+
608
+ if (variant === "compact") {
609
+ return (
610
+ <div className="flex items-center justify-between py-2 border-b last:border-b-0">
611
+ <div className="flex items-center gap-2">
612
+ {metric.icon && <div className={cn("h-4 w-4", colors.icon)}>{metric.icon}</div>}
613
+ <span className="text-sm font-medium">{metric.name}</span>
614
+ </div>
615
+ <div className="flex items-center gap-3">
616
+ <AnimatedValue
617
+ value={metric.value.current}
618
+ format={metric.format}
619
+ unit={metric.unit}
620
+ animated={animated}
621
+ className="font-semibold"
622
+ />
623
+ <div className="flex items-center gap-1">
624
+ <TrendArrow
625
+ direction={direction}
626
+ invert={metric.invertTrend}
627
+ size="sm"
628
+ animated={animated}
629
+ />
630
+ <span
631
+ className={cn(
632
+ "text-xs font-medium",
633
+ (metric.invertTrend ? direction === "down" : direction === "up") && "text-green-600",
634
+ (metric.invertTrend ? direction === "up" : direction === "down") && "text-red-600",
635
+ direction === "neutral" && "text-muted-foreground"
636
+ )}
637
+ >
638
+ {formatPercentage(percentage)}
639
+ </span>
640
+ </div>
641
+ </div>
642
+ </div>
643
+ )
644
+ }
645
+
646
+ if (variant === "list") {
647
+ return (
648
+ <div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
649
+ {/* Icon */}
650
+ {metric.icon && (
651
+ <div className={cn("rounded-lg p-2", colors.barBg)}>
652
+ <div className={cn("h-5 w-5", colors.icon)}>{metric.icon}</div>
653
+ </div>
654
+ )}
655
+
656
+ {/* Name and values */}
657
+ <div className="flex-1 min-w-0">
658
+ <div className="flex items-center justify-between mb-1">
659
+ <span className="font-medium truncate">{metric.name}</span>
660
+ <div className="flex items-center gap-2">
661
+ <TrendArrow
662
+ direction={direction}
663
+ invert={metric.invertTrend}
664
+ animated={animated}
665
+ />
666
+ <span
667
+ className={cn(
668
+ "text-sm font-semibold",
669
+ (metric.invertTrend ? direction === "down" : direction === "up") && "text-green-600",
670
+ (metric.invertTrend ? direction === "up" : direction === "down") && "text-red-600",
671
+ direction === "neutral" && "text-muted-foreground"
672
+ )}
673
+ >
674
+ {formatPercentage(percentage)}
675
+ </span>
676
+ </div>
677
+ </div>
678
+
679
+ <div className="flex items-baseline gap-3">
680
+ <AnimatedValue
681
+ value={metric.value.current}
682
+ format={metric.format}
683
+ unit={metric.unit}
684
+ animated={animated}
685
+ className="text-2xl font-bold"
686
+ />
687
+ <span className="text-sm text-muted-foreground">
688
+ from{" "}
689
+ <AnimatedValue
690
+ value={metric.value.previous}
691
+ format={metric.format}
692
+ unit={metric.unit}
693
+ animated={animated}
694
+ className="font-medium"
695
+ />
696
+ </span>
697
+ </div>
698
+
699
+ {showComparisonBars && (
700
+ <div className="mt-3">
701
+ <ComparisonBar
702
+ current={metric.value.current}
703
+ previous={metric.value.previous}
704
+ color={metric.color}
705
+ animated={animated}
706
+ />
707
+ </div>
708
+ )}
709
+ </div>
710
+ </div>
711
+ )
712
+ }
713
+
714
+ // Grid variant (default)
715
+ return (
716
+ <div className="p-4 rounded-lg border bg-card">
717
+ {/* Header */}
718
+ <div className="flex items-start justify-between mb-3">
719
+ <div className="flex items-center gap-2">
720
+ {metric.icon && (
721
+ <div className={cn("rounded-lg p-1.5", colors.barBg)}>
722
+ <div className={cn("h-4 w-4", colors.icon)}>{metric.icon}</div>
723
+ </div>
724
+ )}
725
+ <span className="text-sm font-medium text-muted-foreground">{metric.name}</span>
726
+ </div>
727
+ <TrendArrow direction={direction} invert={metric.invertTrend} animated={animated} />
728
+ </div>
729
+
730
+ {/* Current value */}
731
+ <div className="mb-2">
732
+ <AnimatedValue
733
+ value={metric.value.current}
734
+ format={metric.format}
735
+ unit={metric.unit}
736
+ animated={animated}
737
+ className="text-2xl font-bold"
738
+ />
739
+ </div>
740
+
741
+ {/* Change indicators */}
742
+ <div className="flex items-center gap-2 text-sm">
743
+ <span
744
+ className={cn(
745
+ "font-semibold",
746
+ (metric.invertTrend ? direction === "down" : direction === "up") && "text-green-600",
747
+ (metric.invertTrend ? direction === "up" : direction === "down") && "text-red-600",
748
+ direction === "neutral" && "text-muted-foreground"
749
+ )}
750
+ >
751
+ {formatPercentage(percentage)}
752
+ </span>
753
+ <span className="text-muted-foreground">
754
+ ({formatAbsolute(absolute, metric.format, metric.unit)})
755
+ </span>
756
+ </div>
757
+
758
+ {/* Previous value */}
759
+ <div className="mt-1 text-xs text-muted-foreground">
760
+ Previous:{" "}
761
+ <AnimatedValue
762
+ value={metric.value.previous}
763
+ format={metric.format}
764
+ unit={metric.unit}
765
+ animated={animated}
766
+ className="font-medium"
767
+ />
768
+ </div>
769
+
770
+ {/* Comparison bars */}
771
+ {showComparisonBars && (
772
+ <div className="mt-4">
773
+ <ComparisonBar
774
+ current={metric.value.current}
775
+ previous={metric.value.previous}
776
+ color={metric.color}
777
+ animated={animated}
778
+ />
779
+ </div>
780
+ )}
781
+ </div>
782
+ )
783
+ }
784
+
785
+ // ============================================================================
786
+ // Summary Card Component
787
+ // ============================================================================
788
+
789
+ interface SummaryCardProps {
790
+ summary: ComparePeriodSummary
791
+ metrics: Metric[]
792
+ animated: boolean
793
+ }
794
+
795
+ function SummaryCard({ summary, metrics, animated }: SummaryCardProps) {
796
+ const bestMetric = metrics.find((m) => m.id === summary.bestMetricId)
797
+ const worstMetric = metrics.find((m) => m.id === summary.worstMetricId)
798
+
799
+ return (
800
+ <div className="p-4 rounded-lg border bg-card">
801
+ <div className="flex items-center gap-2 mb-4">
802
+ <BarChart3 className="h-5 w-5 text-muted-foreground" />
803
+ <span className="font-semibold">Summary</span>
804
+ </div>
805
+
806
+ {/* Stats row */}
807
+ <div className="grid grid-cols-3 gap-4 mb-4">
808
+ <div className="text-center">
809
+ <div className="flex items-center justify-center gap-1 text-green-600 mb-1">
810
+ <TrendingUp className="h-4 w-4" />
811
+ <span className="text-xl font-bold">{summary.improving}</span>
812
+ </div>
813
+ <div className="text-xs text-muted-foreground">Improving</div>
814
+ </div>
815
+ <div className="text-center">
816
+ <div className="flex items-center justify-center gap-1 text-red-600 mb-1">
817
+ <TrendingDown className="h-4 w-4" />
818
+ <span className="text-xl font-bold">{summary.declining}</span>
819
+ </div>
820
+ <div className="text-xs text-muted-foreground">Declining</div>
821
+ </div>
822
+ <div className="text-center">
823
+ <div className="flex items-center justify-center gap-1 text-muted-foreground mb-1">
824
+ <Minus className="h-4 w-4" />
825
+ <span className="text-xl font-bold">{summary.unchanged}</span>
826
+ </div>
827
+ <div className="text-xs text-muted-foreground">Unchanged</div>
828
+ </div>
829
+ </div>
830
+
831
+ {/* Average change */}
832
+ <div className="p-3 rounded-lg bg-muted/50 mb-3">
833
+ <div className="flex items-center justify-between">
834
+ <span className="text-sm text-muted-foreground">Average Change</span>
835
+ <span
836
+ className={cn(
837
+ "font-bold",
838
+ summary.averageChange > 0 && "text-green-600",
839
+ summary.averageChange < 0 && "text-red-600"
840
+ )}
841
+ >
842
+ {formatPercentage(summary.averageChange)}
843
+ </span>
844
+ </div>
845
+ </div>
846
+
847
+ {/* Best and worst performers */}
848
+ <div className="space-y-2">
849
+ {bestMetric && (
850
+ <div className="flex items-center justify-between text-sm">
851
+ <span className="text-muted-foreground">Best performer</span>
852
+ <span className="font-medium text-green-600">{bestMetric.name}</span>
853
+ </div>
854
+ )}
855
+ {worstMetric && (
856
+ <div className="flex items-center justify-between text-sm">
857
+ <span className="text-muted-foreground">Needs attention</span>
858
+ <span className="font-medium text-red-600">{worstMetric.name}</span>
859
+ </div>
860
+ )}
861
+ </div>
862
+ </div>
863
+ )
864
+ }
865
+
866
+ // ============================================================================
867
+ // Main Component
868
+ // ============================================================================
869
+
870
+ export function WakaComparePeriod({
871
+ metrics,
872
+ currentPeriod,
873
+ previousPeriod,
874
+ periodPreset = "last_week",
875
+ onPeriodChange,
876
+ showPeriodSelector = true,
877
+ showComparisonBars = true,
878
+ showSummary = true,
879
+ animated = true,
880
+ variant = "grid",
881
+ columns = 3,
882
+ className,
883
+ }: WakaComparePeriodProps) {
884
+ const [internalPreset, setInternalPreset] = React.useState(periodPreset)
885
+ const [internalCurrentPeriod, setInternalCurrentPeriod] = React.useState(currentPeriod)
886
+ const [internalPreviousPeriod, setInternalPreviousPeriod] = React.useState(previousPeriod)
887
+
888
+ // Calculate summary
889
+ const summary = React.useMemo((): ComparePeriodSummary => {
890
+ let improving = 0
891
+ let declining = 0
892
+ let unchanged = 0
893
+ let totalPercentage = 0
894
+ let bestChange = -Infinity
895
+ let worstChange = Infinity
896
+ let bestMetricId: string | undefined
897
+ let worstMetricId: string | undefined
898
+
899
+ metrics.forEach((metric) => {
900
+ const { percentage, direction } = calculateChange(
901
+ metric.value.current,
902
+ metric.value.previous
903
+ )
904
+
905
+ // Consider invertTrend for categorization
906
+ const effectiveDirection = metric.invertTrend
907
+ ? direction === "up"
908
+ ? "down"
909
+ : direction === "down"
910
+ ? "up"
911
+ : "neutral"
912
+ : direction
913
+
914
+ if (effectiveDirection === "up") improving++
915
+ else if (effectiveDirection === "down") declining++
916
+ else unchanged++
917
+
918
+ totalPercentage += percentage
919
+
920
+ // Track best and worst (considering invertTrend)
921
+ const effectivePercentage = metric.invertTrend ? -percentage : percentage
922
+ if (effectivePercentage > bestChange) {
923
+ bestChange = effectivePercentage
924
+ bestMetricId = metric.id
925
+ }
926
+ if (effectivePercentage < worstChange) {
927
+ worstChange = effectivePercentage
928
+ worstMetricId = metric.id
929
+ }
930
+ })
931
+
932
+ return {
933
+ improving,
934
+ declining,
935
+ unchanged,
936
+ averageChange: metrics.length > 0 ? totalPercentage / metrics.length : 0,
937
+ bestMetricId,
938
+ worstMetricId,
939
+ }
940
+ }, [metrics])
941
+
942
+ const handlePeriodChange = (preset: PeriodPreset) => {
943
+ setInternalPreset(preset)
944
+
945
+ if (preset !== "custom") {
946
+ const ranges = calculatePeriodRanges(preset)
947
+ setInternalCurrentPeriod(ranges.current)
948
+ setInternalPreviousPeriod(ranges.previous)
949
+ onPeriodChange?.(preset, ranges.current, ranges.previous)
950
+ } else {
951
+ onPeriodChange?.(preset, internalCurrentPeriod, internalPreviousPeriod)
952
+ }
953
+ }
954
+
955
+ const columnClasses = {
956
+ 2: "grid-cols-2",
957
+ 3: "grid-cols-3",
958
+ 4: "grid-cols-4",
959
+ }
960
+
961
+ return (
962
+ <div className={cn("space-y-4", className)}>
963
+ {/* Header */}
964
+ <div className="flex items-center justify-between">
965
+ <div className="flex items-center gap-3">
966
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
967
+ <Calendar className="h-4 w-4" />
968
+ <span>{internalCurrentPeriod.label}</span>
969
+ <ArrowRight className="h-3 w-3" />
970
+ <span>{internalPreviousPeriod.label}</span>
971
+ </div>
972
+ </div>
973
+
974
+ {showPeriodSelector && (
975
+ <PeriodSelector
976
+ selectedPreset={internalPreset}
977
+ onSelect={handlePeriodChange}
978
+ currentPeriod={internalCurrentPeriod}
979
+ previousPeriod={internalPreviousPeriod}
980
+ />
981
+ )}
982
+ </div>
983
+
984
+ {/* Metrics display */}
985
+ {variant === "compact" ? (
986
+ <div className="rounded-lg border bg-card p-4">
987
+ {metrics.map((metric) => (
988
+ <MetricCard
989
+ key={metric.id}
990
+ metric={metric}
991
+ showComparisonBars={false}
992
+ animated={animated}
993
+ variant="compact"
994
+ />
995
+ ))}
996
+ </div>
997
+ ) : variant === "list" ? (
998
+ <div className="space-y-3">
999
+ {metrics.map((metric) => (
1000
+ <MetricCard
1001
+ key={metric.id}
1002
+ metric={metric}
1003
+ showComparisonBars={showComparisonBars}
1004
+ animated={animated}
1005
+ variant="list"
1006
+ />
1007
+ ))}
1008
+ </div>
1009
+ ) : (
1010
+ <div className={cn("grid gap-4", columnClasses[columns])}>
1011
+ {metrics.map((metric) => (
1012
+ <MetricCard
1013
+ key={metric.id}
1014
+ metric={metric}
1015
+ showComparisonBars={showComparisonBars}
1016
+ animated={animated}
1017
+ variant="grid"
1018
+ />
1019
+ ))}
1020
+ </div>
1021
+ )}
1022
+
1023
+ {/* Summary */}
1024
+ {showSummary && metrics.length > 0 && (
1025
+ <SummaryCard summary={summary} metrics={metrics} animated={animated} />
1026
+ )}
1027
+ </div>
1028
+ )
1029
+ }
1030
+
1031
+ // ============================================================================
1032
+ // Hook: useComparePeriod
1033
+ // ============================================================================
1034
+
1035
+ export interface UseComparePeriodOptions {
1036
+ /** Initial period preset */
1037
+ initialPreset?: PeriodPreset
1038
+ /** Initial metrics data */
1039
+ initialMetrics?: Metric[]
1040
+ /** Reference date for calculations */
1041
+ referenceDate?: Date
1042
+ /** Auto-refresh interval in milliseconds */
1043
+ autoRefreshInterval?: number
1044
+ /** Callback to fetch new metric data */
1045
+ onRefresh?: () => Promise<Metric[]>
1046
+ }
1047
+
1048
+ export interface UseComparePeriodReturn {
1049
+ /** Current period preset */
1050
+ preset: PeriodPreset
1051
+ /** Current period range */
1052
+ currentPeriod: PeriodRange
1053
+ /** Previous period range */
1054
+ previousPeriod: PeriodRange
1055
+ /** Metrics array */
1056
+ metrics: Metric[]
1057
+ /** Calculated summary */
1058
+ summary: ComparePeriodSummary
1059
+ /** Loading state */
1060
+ isLoading: boolean
1061
+ /** Error state */
1062
+ error: Error | null
1063
+ /** Set period preset */
1064
+ setPreset: (preset: PeriodPreset) => void
1065
+ /** Set custom period ranges */
1066
+ setCustomPeriods: (current: PeriodRange, previous: PeriodRange) => void
1067
+ /** Update metrics data */
1068
+ setMetrics: React.Dispatch<React.SetStateAction<Metric[]>>
1069
+ /** Update a single metric */
1070
+ updateMetric: (id: string, value: MetricValue) => void
1071
+ /** Trigger manual refresh */
1072
+ refresh: () => Promise<void>
1073
+ /** Get change for a specific metric */
1074
+ getMetricChange: (id: string) => { percentage: number; absolute: number; direction: TrendDirection } | null
1075
+ }
1076
+
1077
+ export function useComparePeriod(
1078
+ options: UseComparePeriodOptions = {}
1079
+ ): UseComparePeriodReturn {
1080
+ const {
1081
+ initialPreset = "last_week",
1082
+ initialMetrics = [],
1083
+ referenceDate = new Date(),
1084
+ autoRefreshInterval,
1085
+ onRefresh,
1086
+ } = options
1087
+
1088
+ const [preset, setPreset] = React.useState(initialPreset)
1089
+ const [metrics, setMetrics] = React.useState<Metric[]>(initialMetrics)
1090
+ const [isLoading, setIsLoading] = React.useState(false)
1091
+ const [error, setError] = React.useState<Error | null>(null)
1092
+
1093
+ const [periods, setPeriods] = React.useState(() => calculatePeriodRanges(initialPreset, referenceDate))
1094
+
1095
+ // Update periods when preset changes
1096
+ React.useEffect(() => {
1097
+ if (preset !== "custom") {
1098
+ setPeriods(calculatePeriodRanges(preset, referenceDate))
1099
+ }
1100
+ }, [preset, referenceDate])
1101
+
1102
+ // Auto-refresh
1103
+ React.useEffect(() => {
1104
+ if (!autoRefreshInterval || !onRefresh) return
1105
+
1106
+ const interval = setInterval(async () => {
1107
+ try {
1108
+ const newMetrics = await onRefresh()
1109
+ setMetrics(newMetrics)
1110
+ } catch (err) {
1111
+ setError(err instanceof Error ? err : new Error("Refresh failed"))
1112
+ }
1113
+ }, autoRefreshInterval)
1114
+
1115
+ return () => clearInterval(interval)
1116
+ }, [autoRefreshInterval, onRefresh])
1117
+
1118
+ // Calculate summary
1119
+ const summary = React.useMemo((): ComparePeriodSummary => {
1120
+ let improving = 0
1121
+ let declining = 0
1122
+ let unchanged = 0
1123
+ let totalPercentage = 0
1124
+ let bestChange = -Infinity
1125
+ let worstChange = Infinity
1126
+ let bestMetricId: string | undefined
1127
+ let worstMetricId: string | undefined
1128
+
1129
+ metrics.forEach((metric) => {
1130
+ const { percentage, direction } = calculateChange(
1131
+ metric.value.current,
1132
+ metric.value.previous
1133
+ )
1134
+
1135
+ const effectiveDirection = metric.invertTrend
1136
+ ? direction === "up"
1137
+ ? "down"
1138
+ : direction === "down"
1139
+ ? "up"
1140
+ : "neutral"
1141
+ : direction
1142
+
1143
+ if (effectiveDirection === "up") improving++
1144
+ else if (effectiveDirection === "down") declining++
1145
+ else unchanged++
1146
+
1147
+ totalPercentage += percentage
1148
+
1149
+ const effectivePercentage = metric.invertTrend ? -percentage : percentage
1150
+ if (effectivePercentage > bestChange) {
1151
+ bestChange = effectivePercentage
1152
+ bestMetricId = metric.id
1153
+ }
1154
+ if (effectivePercentage < worstChange) {
1155
+ worstChange = effectivePercentage
1156
+ worstMetricId = metric.id
1157
+ }
1158
+ })
1159
+
1160
+ return {
1161
+ improving,
1162
+ declining,
1163
+ unchanged,
1164
+ averageChange: metrics.length > 0 ? totalPercentage / metrics.length : 0,
1165
+ bestMetricId,
1166
+ worstMetricId,
1167
+ }
1168
+ }, [metrics])
1169
+
1170
+ const setCustomPeriods = React.useCallback(
1171
+ (current: PeriodRange, previous: PeriodRange) => {
1172
+ setPreset("custom")
1173
+ setPeriods({ current, previous })
1174
+ },
1175
+ []
1176
+ )
1177
+
1178
+ const updateMetric = React.useCallback((id: string, value: MetricValue) => {
1179
+ setMetrics((prev) =>
1180
+ prev.map((m) => (m.id === id ? { ...m, value } : m))
1181
+ )
1182
+ }, [])
1183
+
1184
+ const refresh = React.useCallback(async () => {
1185
+ if (!onRefresh) return
1186
+
1187
+ setIsLoading(true)
1188
+ setError(null)
1189
+
1190
+ try {
1191
+ const newMetrics = await onRefresh()
1192
+ setMetrics(newMetrics)
1193
+ } catch (err) {
1194
+ setError(err instanceof Error ? err : new Error("Refresh failed"))
1195
+ } finally {
1196
+ setIsLoading(false)
1197
+ }
1198
+ }, [onRefresh])
1199
+
1200
+ const getMetricChange = React.useCallback(
1201
+ (id: string) => {
1202
+ const metric = metrics.find((m) => m.id === id)
1203
+ if (!metric) return null
1204
+ return calculateChange(metric.value.current, metric.value.previous)
1205
+ },
1206
+ [metrics]
1207
+ )
1208
+
1209
+ return {
1210
+ preset,
1211
+ currentPeriod: periods.current,
1212
+ previousPeriod: periods.previous,
1213
+ metrics,
1214
+ summary,
1215
+ isLoading,
1216
+ error,
1217
+ setPreset,
1218
+ setCustomPeriods,
1219
+ setMetrics,
1220
+ updateMetric,
1221
+ refresh,
1222
+ getMetricChange,
1223
+ }
1224
+ }
1225
+
1226
+ // ============================================================================
1227
+ // Exports
1228
+ // ============================================================================
1229
+
1230
+ export default WakaComparePeriod