@wakastellar/ui 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/dist/cli/commands/add.d.ts +7 -0
  2. package/dist/cli/commands/init.d.ts +6 -0
  3. package/dist/cli/commands/list.d.ts +5 -0
  4. package/dist/cli/commands/search.d.ts +1 -0
  5. package/dist/cli/index.cjs +4844 -0
  6. package/dist/cli/index.d.ts +1 -0
  7. package/dist/cli/utils/config.d.ts +29 -0
  8. package/dist/cli/utils/logger.d.ts +20 -0
  9. package/dist/cli/utils/registry.d.ts +23 -0
  10. package/package.json +14 -3
  11. package/src/blocks/activity-timeline/index.tsx +586 -0
  12. package/src/blocks/calendar-view/index.tsx +756 -0
  13. package/src/blocks/chat/index.tsx +1018 -0
  14. package/src/blocks/chat/widget.tsx +504 -0
  15. package/src/blocks/dashboard/index.tsx +522 -0
  16. package/src/blocks/empty-states/index.tsx +452 -0
  17. package/src/blocks/error-pages/index.tsx +426 -0
  18. package/src/blocks/faq/index.tsx +479 -0
  19. package/src/blocks/file-manager/index.tsx +890 -0
  20. package/src/blocks/footer/index.tsx +133 -0
  21. package/src/blocks/header/index.tsx +357 -0
  22. package/src/blocks/headtab/index.tsx +139 -0
  23. package/src/blocks/i18n-editor/index.tsx +1016 -0
  24. package/src/blocks/index.ts +80 -0
  25. package/src/blocks/kanban-board/index.tsx +779 -0
  26. package/src/blocks/landing/index.tsx +677 -0
  27. package/src/blocks/language-selector/index.tsx +88 -0
  28. package/src/blocks/layout/index.tsx +159 -0
  29. package/src/blocks/login/index.tsx +339 -0
  30. package/src/blocks/login/types.ts +131 -0
  31. package/src/blocks/pricing/index.tsx +564 -0
  32. package/src/blocks/profile/index.tsx +746 -0
  33. package/src/blocks/settings/index.tsx +558 -0
  34. package/src/blocks/sidebar/index.tsx +713 -0
  35. package/src/blocks/theme-creator-block/index.tsx +835 -0
  36. package/src/blocks/user-management/index.tsx +1037 -0
  37. package/src/blocks/wizard/index.tsx +719 -0
  38. package/src/components/DataTable/DataTable.tsx +406 -0
  39. package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
  40. package/src/components/DataTable/DataTableBody.tsx +216 -0
  41. package/src/components/DataTable/DataTableCell.tsx +172 -0
  42. package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
  43. package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
  44. package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
  45. package/src/components/DataTable/DataTableEditCell.tsx +279 -0
  46. package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
  47. package/src/components/DataTable/DataTableFilters.tsx +535 -0
  48. package/src/components/DataTable/DataTableGrouping.tsx +147 -0
  49. package/src/components/DataTable/DataTableHeader.tsx +172 -0
  50. package/src/components/DataTable/DataTablePagination.tsx +125 -0
  51. package/src/components/DataTable/DataTableSelection.tsx +269 -0
  52. package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
  53. package/src/components/DataTable/DataTableToolbar.tsx +262 -0
  54. package/src/components/DataTable/README.md +446 -0
  55. package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
  56. package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
  57. package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
  58. package/src/components/DataTable/examples/EditExample.tsx +166 -0
  59. package/src/components/DataTable/formatters/index.ts +335 -0
  60. package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
  61. package/src/components/DataTable/hooks/useDataTable.ts +145 -0
  62. package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
  63. package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
  64. package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
  65. package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
  66. package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
  67. package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
  68. package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
  69. package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
  70. package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
  71. package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
  72. package/src/components/DataTable/index.ts +81 -0
  73. package/src/components/DataTable/services/IndexedDBService.ts +504 -0
  74. package/src/components/DataTable/templates/index.tsx +803 -0
  75. package/src/components/DataTable/types.ts +504 -0
  76. package/src/components/DataTable/utils.ts +164 -0
  77. package/src/components/DataTable/workers/exportWorker.ts +213 -0
  78. package/src/components/accordion/index.tsx +61 -0
  79. package/src/components/alert/index.tsx +61 -0
  80. package/src/components/alert-dialog/index.tsx +146 -0
  81. package/src/components/aspect-ratio/index.tsx +12 -0
  82. package/src/components/avatar/index.tsx +54 -0
  83. package/src/components/badge/Badge.stories.tsx +64 -0
  84. package/src/components/badge/index.tsx +38 -0
  85. package/src/components/button/Button.stories.tsx +173 -0
  86. package/src/components/button/index.tsx +56 -0
  87. package/src/components/calendar/index.tsx +73 -0
  88. package/src/components/card/index.tsx +78 -0
  89. package/src/components/checkbox/index.tsx +34 -0
  90. package/src/components/code/index.tsx +229 -0
  91. package/src/components/collapsible/index.tsx +16 -0
  92. package/src/components/command/index.tsx +162 -0
  93. package/src/components/context-menu/index.tsx +204 -0
  94. package/src/components/dialog/index.tsx +126 -0
  95. package/src/components/dropdown-menu/index.tsx +204 -0
  96. package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
  97. package/src/components/error-boundary/index.ts +7 -0
  98. package/src/components/form/index.tsx +183 -0
  99. package/src/components/hover-card/index.tsx +33 -0
  100. package/src/components/index.ts +368 -0
  101. package/src/components/input/Input.stories.tsx +100 -0
  102. package/src/components/input/index.tsx +27 -0
  103. package/src/components/input-otp/index.tsx +277 -0
  104. package/src/components/label/index.tsx +30 -0
  105. package/src/components/language-selector/index.tsx +341 -0
  106. package/src/components/menubar/index.tsx +240 -0
  107. package/src/components/navigation-menu/index.tsx +134 -0
  108. package/src/components/popover/index.tsx +35 -0
  109. package/src/components/progress/index.tsx +32 -0
  110. package/src/components/radio-group/index.tsx +48 -0
  111. package/src/components/scroll-area/index.tsx +52 -0
  112. package/src/components/select/index.tsx +164 -0
  113. package/src/components/separator/index.tsx +35 -0
  114. package/src/components/sheet/index.tsx +147 -0
  115. package/src/components/skeleton/index.tsx +22 -0
  116. package/src/components/slider/index.tsx +32 -0
  117. package/src/components/switch/index.tsx +33 -0
  118. package/src/components/table/index.tsx +117 -0
  119. package/src/components/tabs/index.tsx +59 -0
  120. package/src/components/textarea/index.tsx +30 -0
  121. package/src/components/theme-selector/index.tsx +327 -0
  122. package/src/components/toast/index.tsx +133 -0
  123. package/src/components/toaster/index.tsx +34 -0
  124. package/src/components/toggle/index.tsx +49 -0
  125. package/src/components/tooltip/index.tsx +34 -0
  126. package/src/components/typography/index.tsx +276 -0
  127. package/src/components/waka-3d-pie-chart/index.tsx +486 -0
  128. package/src/components/waka-achievement-unlock/index.tsx +716 -0
  129. package/src/components/waka-activity-feed/index.tsx +686 -0
  130. package/src/components/waka-address-autocomplete/index.tsx +1202 -0
  131. package/src/components/waka-admincrumb/index.tsx +349 -0
  132. package/src/components/waka-alert-stack/index.tsx +827 -0
  133. package/src/components/waka-allocation-matrix/index.tsx +1278 -0
  134. package/src/components/waka-approval-chain/index.tsx +766 -0
  135. package/src/components/waka-audit-log/index.tsx +1475 -0
  136. package/src/components/waka-autocomplete/index.tsx +358 -0
  137. package/src/components/waka-badge-showcase/index.tsx +704 -0
  138. package/src/components/waka-barcode/index.tsx +260 -0
  139. package/src/components/waka-biometric-prompt/index.tsx +765 -0
  140. package/src/components/waka-bottom-sheet/index.tsx +495 -0
  141. package/src/components/waka-breadcrumb/index.tsx +376 -0
  142. package/src/components/waka-breadcrumb-path/index.tsx +513 -0
  143. package/src/components/waka-budget-burn/index.tsx +1234 -0
  144. package/src/components/waka-capacity-planner/index.tsx +1107 -0
  145. package/src/components/waka-carousel/index.tsx +893 -0
  146. package/src/components/waka-cart-summary/index.tsx +1055 -0
  147. package/src/components/waka-challenge-timer/index.tsx +1044 -0
  148. package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
  149. package/src/components/waka-charts/WakaBarChart.tsx +222 -0
  150. package/src/components/waka-charts/WakaChart.tsx +124 -0
  151. package/src/components/waka-charts/WakaLineChart.tsx +219 -0
  152. package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
  153. package/src/components/waka-charts/WakaPieChart.tsx +214 -0
  154. package/src/components/waka-charts/WakaSparkline.tsx +229 -0
  155. package/src/components/waka-charts/dataTableHelpers.ts +109 -0
  156. package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
  157. package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
  158. package/src/components/waka-charts/index.ts +90 -0
  159. package/src/components/waka-charts/types.ts +330 -0
  160. package/src/components/waka-chat-bubble/index.tsx +1060 -0
  161. package/src/components/waka-checklist/index.tsx +1067 -0
  162. package/src/components/waka-checkout-stepper/index.tsx +976 -0
  163. package/src/components/waka-cohort-table/index.tsx +1011 -0
  164. package/src/components/waka-color-picker/index.tsx +447 -0
  165. package/src/components/waka-combo-counter/index.tsx +864 -0
  166. package/src/components/waka-combobox/index.tsx +497 -0
  167. package/src/components/waka-command-bar/index.tsx +403 -0
  168. package/src/components/waka-compare-period/index.tsx +1230 -0
  169. package/src/components/waka-connection-matrix/index.tsx +1053 -0
  170. package/src/components/waka-contribution-graph/index.tsx +552 -0
  171. package/src/components/waka-cost-breakdown/index.tsx +1065 -0
  172. package/src/components/waka-coupon-input/index.tsx +592 -0
  173. package/src/components/waka-credit-card-input/index.tsx +982 -0
  174. package/src/components/waka-daily-reward/index.tsx +762 -0
  175. package/src/components/waka-date-range-picker/index.tsx +378 -0
  176. package/src/components/waka-datetime-picker/index.tsx +793 -0
  177. package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
  178. package/src/components/waka-deployment-lane/index.tsx +673 -0
  179. package/src/components/waka-device-trust/index.tsx +1259 -0
  180. package/src/components/waka-dock/index.tsx +285 -0
  181. package/src/components/waka-drawer/index.tsx +319 -0
  182. package/src/components/waka-empty-state/index.tsx +545 -0
  183. package/src/components/waka-error-shake/index.tsx +398 -0
  184. package/src/components/waka-feature-announcement/index.tsx +991 -0
  185. package/src/components/waka-file-upload/index.tsx +437 -0
  186. package/src/components/waka-floating-nav/index.tsx +413 -0
  187. package/src/components/waka-flow-diagram/index.tsx +508 -0
  188. package/src/components/waka-funnel-chart/index.tsx +823 -0
  189. package/src/components/waka-glow-card/index.tsx +246 -0
  190. package/src/components/waka-goal-progress/index.tsx +1025 -0
  191. package/src/components/waka-haptic-button/index.tsx +388 -0
  192. package/src/components/waka-health-pulse/index.tsx +451 -0
  193. package/src/components/waka-heatmap/index.tsx +1026 -0
  194. package/src/components/waka-hotspot/index.tsx +682 -0
  195. package/src/components/waka-image/index.tsx +373 -0
  196. package/src/components/waka-incident-timeline/index.tsx +686 -0
  197. package/src/components/waka-invoice-preview/index.tsx +829 -0
  198. package/src/components/waka-kanban/index.tsx +646 -0
  199. package/src/components/waka-kpi-dashboard/index.tsx +755 -0
  200. package/src/components/waka-leaderboard/index.tsx +746 -0
  201. package/src/components/waka-level-progress/index.tsx +665 -0
  202. package/src/components/waka-liquid-button/index.tsx +520 -0
  203. package/src/components/waka-loading-orbit/index.tsx +478 -0
  204. package/src/components/waka-loot-box/index.tsx +1091 -0
  205. package/src/components/waka-magic-link/index.tsx +321 -0
  206. package/src/components/waka-magnetic-button/index.tsx +567 -0
  207. package/src/components/waka-mention-input/index.tsx +953 -0
  208. package/src/components/waka-metric-sparkline/index.tsx +627 -0
  209. package/src/components/waka-milestone-road/index.tsx +1064 -0
  210. package/src/components/waka-modal/index.tsx +374 -0
  211. package/src/components/waka-morph-button/index.tsx +495 -0
  212. package/src/components/waka-network-topology/index.tsx +801 -0
  213. package/src/components/waka-notifications/index.tsx +414 -0
  214. package/src/components/waka-number-input/index.tsx +373 -0
  215. package/src/components/waka-orbital-menu/index.tsx +445 -0
  216. package/src/components/waka-order-tracker/index.tsx +1041 -0
  217. package/src/components/waka-pagination/index.tsx +393 -0
  218. package/src/components/waka-password-strength/index.tsx +824 -0
  219. package/src/components/waka-payment-method-picker/index.tsx +715 -0
  220. package/src/components/waka-permission-matrix/index.tsx +1302 -0
  221. package/src/components/waka-phone-input/index.tsx +801 -0
  222. package/src/components/waka-pipeline-view/index.tsx +604 -0
  223. package/src/components/waka-player-card/index.tsx +691 -0
  224. package/src/components/waka-points-popup/index.tsx +366 -0
  225. package/src/components/waka-power-up/index.tsx +1155 -0
  226. package/src/components/waka-presence-indicator/index.tsx +1181 -0
  227. package/src/components/waka-pricing-table/index.tsx +755 -0
  228. package/src/components/waka-product-card/index.tsx +786 -0
  229. package/src/components/waka-progress-onboarding/index.tsx +878 -0
  230. package/src/components/waka-pull-to-refresh/index.tsx +451 -0
  231. package/src/components/waka-qrcode/index.tsx +232 -0
  232. package/src/components/waka-quest-card/index.tsx +1275 -0
  233. package/src/components/waka-quota-bar/index.tsx +693 -0
  234. package/src/components/waka-radar-score/index.tsx +512 -0
  235. package/src/components/waka-rank-badge/index.tsx +813 -0
  236. package/src/components/waka-rating-input/index.tsx +560 -0
  237. package/src/components/waka-reaction-picker/index.tsx +1062 -0
  238. package/src/components/waka-region-map/index.tsx +730 -0
  239. package/src/components/waka-resource-gauge/index.tsx +654 -0
  240. package/src/components/waka-resource-pool/index.tsx +1035 -0
  241. package/src/components/waka-rich-text-editor/index.tsx +594 -0
  242. package/src/components/waka-rollback-slider/index.tsx +891 -0
  243. package/src/components/waka-sankey-diagram/index.tsx +1032 -0
  244. package/src/components/waka-schedule-picker/index.tsx +1060 -0
  245. package/src/components/waka-scratch-card/index.tsx +914 -0
  246. package/src/components/waka-season-pass/index.tsx +886 -0
  247. package/src/components/waka-security-score/index.tsx +1126 -0
  248. package/src/components/waka-segmented-control/index.tsx +238 -0
  249. package/src/components/waka-server-rack/index.tsx +764 -0
  250. package/src/components/waka-session-manager/index.tsx +815 -0
  251. package/src/components/waka-signature-pad/index.tsx +744 -0
  252. package/src/components/waka-skeleton-wave/index.tsx +454 -0
  253. package/src/components/waka-skill-tree/index.tsx +1031 -0
  254. package/src/components/waka-sla-tracker/index.tsx +798 -0
  255. package/src/components/waka-slider-range/index.tsx +765 -0
  256. package/src/components/waka-spin-wheel/index.tsx +671 -0
  257. package/src/components/waka-spinner/index.tsx +284 -0
  258. package/src/components/waka-spotlight/index.tsx +410 -0
  259. package/src/components/waka-stat/index.tsx +428 -0
  260. package/src/components/waka-stats-hexagon/index.tsx +824 -0
  261. package/src/components/waka-status-matrix/index.tsx +565 -0
  262. package/src/components/waka-stepper/index.tsx +489 -0
  263. package/src/components/waka-streak-counter/index.tsx +334 -0
  264. package/src/components/waka-success-explosion/index.tsx +453 -0
  265. package/src/components/waka-swipe-card/index.tsx +574 -0
  266. package/src/components/waka-tabs-morph/index.tsx +509 -0
  267. package/src/components/waka-tag-input/index.tsx +877 -0
  268. package/src/components/waka-team-banner/index.tsx +1183 -0
  269. package/src/components/waka-terminal-output/index.tsx +836 -0
  270. package/src/components/waka-theme-creator/index.tsx +762 -0
  271. package/src/components/waka-theme-manager/index.tsx +654 -0
  272. package/src/components/waka-thread-view/index.tsx +874 -0
  273. package/src/components/waka-tilt-card/index.tsx +250 -0
  274. package/src/components/waka-time-picker/index.tsx +479 -0
  275. package/src/components/waka-timeline/index.tsx +385 -0
  276. package/src/components/waka-tooltip-tour/index.tsx +855 -0
  277. package/src/components/waka-tour-guide/index.tsx +920 -0
  278. package/src/components/waka-tournament-bracket/index.tsx +1276 -0
  279. package/src/components/waka-tree/index.tsx +557 -0
  280. package/src/components/waka-treemap-chart/index.tsx +1031 -0
  281. package/src/components/waka-two-factor-setup/index.tsx +995 -0
  282. package/src/components/waka-typewriter/index.tsx +566 -0
  283. package/src/components/waka-typing-indicator/index.tsx +649 -0
  284. package/src/components/waka-versus-card/index.tsx +1026 -0
  285. package/src/components/waka-video/index.tsx +557 -0
  286. package/src/components/waka-video-call/index.tsx +1087 -0
  287. package/src/components/waka-virtual-list/index.tsx +327 -0
  288. package/src/components/waka-voice-message/index.tsx +1019 -0
  289. package/src/components/waka-welcome-modal/index.tsx +790 -0
  290. package/src/components/waka-xp-bar/index.tsx +799 -0
@@ -0,0 +1,1011 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cn } from "../../utils/cn"
5
+ import { Download, Info, TrendingDown, TrendingUp, Users } from "lucide-react"
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ export type CohortPeriod = "day" | "week" | "month" | "quarter" | "year"
12
+
13
+ export type CohortMetric = "retention" | "churn" | "revenue" | "engagement" | "custom"
14
+
15
+ export interface CohortDataPoint {
16
+ /** Value for this period (0-100 for percentages, or raw value) */
17
+ value: number
18
+ /** Number of users/items at this point */
19
+ count?: number
20
+ /** Additional metadata */
21
+ metadata?: Record<string, unknown>
22
+ }
23
+
24
+ export interface CohortRow {
25
+ /** Unique identifier for the cohort */
26
+ id: string
27
+ /** Cohort label (e.g., "Jan 2024", "Week 1") */
28
+ label: string
29
+ /** Cohort start date */
30
+ date: Date
31
+ /** Initial cohort size */
32
+ size: number
33
+ /** Data points for each period */
34
+ data: CohortDataPoint[]
35
+ }
36
+
37
+ export interface CohortColorScale {
38
+ /** Minimum value color (e.g., for 0% retention) */
39
+ min: string
40
+ /** Maximum value color (e.g., for 100% retention) */
41
+ max: string
42
+ /** Optional midpoint color for gradient */
43
+ mid?: string
44
+ /** Midpoint value (defaults to 50) */
45
+ midValue?: number
46
+ }
47
+
48
+ export interface CohortExportOptions {
49
+ /** Export format */
50
+ format: "csv" | "json"
51
+ /** Include metadata in export */
52
+ includeMetadata?: boolean
53
+ /** Custom filename */
54
+ filename?: string
55
+ }
56
+
57
+ export interface WakaCohortTableProps {
58
+ /** Cohort data rows */
59
+ data: CohortRow[]
60
+ /** Period type for columns */
61
+ period?: CohortPeriod
62
+ /** Metric being displayed */
63
+ metric?: CohortMetric
64
+ /** Custom metric label */
65
+ metricLabel?: string
66
+ /** Whether to show as percentage */
67
+ showAsPercentage?: boolean
68
+ /** Number of decimal places */
69
+ decimalPlaces?: number
70
+ /** Color scale configuration */
71
+ colorScale?: CohortColorScale
72
+ /** Show cohort size column */
73
+ showCohortSize?: boolean
74
+ /** Show average row */
75
+ showAverageRow?: boolean
76
+ /** Show trend indicators */
77
+ showTrends?: boolean
78
+ /** Enable cell hover highlighting */
79
+ highlightOnHover?: boolean
80
+ /** Show color scale legend */
81
+ showLegend?: boolean
82
+ /** Enable export functionality */
83
+ enableExport?: boolean
84
+ /** Callback when cell is clicked */
85
+ onCellClick?: (row: CohortRow, periodIndex: number, value: CohortDataPoint) => void
86
+ /** Callback when export is triggered */
87
+ onExport?: (options: CohortExportOptions) => void
88
+ /** Custom cell renderer */
89
+ renderCell?: (value: CohortDataPoint, row: CohortRow, periodIndex: number) => React.ReactNode
90
+ /** Custom tooltip renderer */
91
+ renderTooltip?: (value: CohortDataPoint, row: CohortRow, periodIndex: number) => React.ReactNode
92
+ /** Custom className */
93
+ className?: string
94
+ /** Table className */
95
+ tableClassName?: string
96
+ /** Header cell className */
97
+ headerClassName?: string
98
+ /** Body cell className */
99
+ cellClassName?: string
100
+ }
101
+
102
+ // ============================================================================
103
+ // Default Color Scale
104
+ // ============================================================================
105
+
106
+ const defaultColorScale: CohortColorScale = {
107
+ min: "#fef2f2", // red-50
108
+ mid: "#fef9c3", // yellow-100
109
+ max: "#dcfce7", // green-100
110
+ midValue: 50,
111
+ }
112
+
113
+ // ============================================================================
114
+ // Helper Functions
115
+ // ============================================================================
116
+
117
+ function interpolateColor(
118
+ value: number,
119
+ min: number,
120
+ max: number,
121
+ colorScale: CohortColorScale
122
+ ): string {
123
+ const normalizedValue = Math.max(min, Math.min(max, value))
124
+ const percentage = (normalizedValue - min) / (max - min)
125
+
126
+ // Parse hex colors to RGB
127
+ const parseHex = (hex: string) => {
128
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
129
+ return result
130
+ ? {
131
+ r: parseInt(result[1], 16),
132
+ g: parseInt(result[2], 16),
133
+ b: parseInt(result[3], 16),
134
+ }
135
+ : { r: 255, g: 255, b: 255 }
136
+ }
137
+
138
+ const minColor = parseHex(colorScale.min)
139
+ const maxColor = parseHex(colorScale.max)
140
+ const midColor = colorScale.mid ? parseHex(colorScale.mid) : null
141
+ const midValue = (colorScale.midValue ?? 50) / 100
142
+
143
+ let r: number, g: number, b: number
144
+
145
+ if (midColor && percentage <= midValue) {
146
+ // Interpolate between min and mid
147
+ const t = percentage / midValue
148
+ r = Math.round(minColor.r + (midColor.r - minColor.r) * t)
149
+ g = Math.round(minColor.g + (midColor.g - minColor.g) * t)
150
+ b = Math.round(minColor.b + (midColor.b - minColor.b) * t)
151
+ } else if (midColor && percentage > midValue) {
152
+ // Interpolate between mid and max
153
+ const t = (percentage - midValue) / (1 - midValue)
154
+ r = Math.round(midColor.r + (maxColor.r - midColor.r) * t)
155
+ g = Math.round(midColor.g + (maxColor.g - midColor.g) * t)
156
+ b = Math.round(midColor.b + (maxColor.b - midColor.b) * t)
157
+ } else {
158
+ // No mid color, interpolate directly
159
+ r = Math.round(minColor.r + (maxColor.r - minColor.r) * percentage)
160
+ g = Math.round(minColor.g + (maxColor.g - minColor.g) * percentage)
161
+ b = Math.round(minColor.b + (maxColor.b - minColor.b) * percentage)
162
+ }
163
+
164
+ return `rgb(${r}, ${g}, ${b})`
165
+ }
166
+
167
+ function formatValue(
168
+ value: number,
169
+ showAsPercentage: boolean,
170
+ decimalPlaces: number
171
+ ): string {
172
+ if (showAsPercentage) {
173
+ return `${value.toFixed(decimalPlaces)}%`
174
+ }
175
+ return value.toFixed(decimalPlaces)
176
+ }
177
+
178
+ function formatNumber(num: number): string {
179
+ if (num >= 1000000) {
180
+ return `${(num / 1000000).toFixed(1)}M`
181
+ }
182
+ if (num >= 1000) {
183
+ return `${(num / 1000).toFixed(1)}K`
184
+ }
185
+ return num.toString()
186
+ }
187
+
188
+ function getPeriodLabel(period: CohortPeriod, index: number): string {
189
+ const labels: Record<CohortPeriod, string> = {
190
+ day: "Day",
191
+ week: "Week",
192
+ month: "Month",
193
+ quarter: "Q",
194
+ year: "Year",
195
+ }
196
+ return `${labels[period]} ${index}`
197
+ }
198
+
199
+ function calculateAverages(data: CohortRow[]): CohortDataPoint[] {
200
+ if (data.length === 0) return []
201
+
202
+ const maxPeriods = Math.max(...data.map((row) => row.data.length))
203
+ const averages: CohortDataPoint[] = []
204
+
205
+ for (let i = 0; i < maxPeriods; i++) {
206
+ const values = data
207
+ .map((row) => row.data[i]?.value)
208
+ .filter((v): v is number => v !== undefined)
209
+
210
+ if (values.length > 0) {
211
+ const sum = values.reduce((a, b) => a + b, 0)
212
+ averages.push({
213
+ value: sum / values.length,
214
+ count: values.length,
215
+ })
216
+ }
217
+ }
218
+
219
+ return averages
220
+ }
221
+
222
+ function calculateTrend(current: number, previous: number): "up" | "down" | "neutral" {
223
+ const diff = current - previous
224
+ if (Math.abs(diff) < 0.5) return "neutral"
225
+ return diff > 0 ? "up" : "down"
226
+ }
227
+
228
+ function exportToCSV(data: CohortRow[], period: CohortPeriod, metricLabel: string): string {
229
+ const maxPeriods = Math.max(...data.map((row) => row.data.length))
230
+ const headers = [
231
+ "Cohort",
232
+ "Size",
233
+ ...Array.from({ length: maxPeriods }, (_, i) => getPeriodLabel(period, i)),
234
+ ]
235
+
236
+ const rows = data.map((row) => [
237
+ row.label,
238
+ row.size.toString(),
239
+ ...row.data.map((d) => d.value.toString()),
240
+ ])
241
+
242
+ return [headers.join(","), ...rows.map((r) => r.join(","))].join("\n")
243
+ }
244
+
245
+ function exportToJSON(data: CohortRow[], includeMetadata: boolean): string {
246
+ const exportData = data.map((row) => ({
247
+ id: row.id,
248
+ label: row.label,
249
+ date: row.date.toISOString(),
250
+ size: row.size,
251
+ data: row.data.map((d) => ({
252
+ value: d.value,
253
+ count: d.count,
254
+ ...(includeMetadata && d.metadata ? { metadata: d.metadata } : {}),
255
+ })),
256
+ }))
257
+
258
+ return JSON.stringify(exportData, null, 2)
259
+ }
260
+
261
+ // ============================================================================
262
+ // Tooltip Component
263
+ // ============================================================================
264
+
265
+ interface TooltipProps {
266
+ children: React.ReactNode
267
+ content: React.ReactNode
268
+ visible: boolean
269
+ position: { x: number; y: number }
270
+ }
271
+
272
+ function Tooltip({ children, content, visible, position }: TooltipProps) {
273
+ return (
274
+ <div className="relative inline-block">
275
+ {children}
276
+ {visible && (
277
+ <div
278
+ className="fixed z-50 px-3 py-2 text-sm bg-popover text-popover-foreground rounded-md shadow-lg border max-w-xs"
279
+ style={{
280
+ left: position.x,
281
+ top: position.y - 10,
282
+ transform: "translate(-50%, -100%)",
283
+ }}
284
+ >
285
+ {content}
286
+ <div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-4 border-r-4 border-t-4 border-l-transparent border-r-transparent border-t-popover" />
287
+ </div>
288
+ )}
289
+ </div>
290
+ )
291
+ }
292
+
293
+ // ============================================================================
294
+ // Color Legend Component
295
+ // ============================================================================
296
+
297
+ interface ColorLegendProps {
298
+ colorScale: CohortColorScale
299
+ min: number
300
+ max: number
301
+ showAsPercentage: boolean
302
+ }
303
+
304
+ function ColorLegend({ colorScale, min, max, showAsPercentage }: ColorLegendProps) {
305
+ const steps = 5
306
+ const stepSize = (max - min) / (steps - 1)
307
+
308
+ return (
309
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
310
+ <span>{showAsPercentage ? `${min}%` : min}</span>
311
+ <div className="flex h-3 rounded overflow-hidden">
312
+ {Array.from({ length: 20 }, (_, i) => {
313
+ const value = min + (max - min) * (i / 19)
314
+ return (
315
+ <div
316
+ key={i}
317
+ className="w-3 h-full"
318
+ style={{ backgroundColor: interpolateColor(value, min, max, colorScale) }}
319
+ />
320
+ )
321
+ })}
322
+ </div>
323
+ <span>{showAsPercentage ? `${max}%` : max}</span>
324
+ </div>
325
+ )
326
+ }
327
+
328
+ // ============================================================================
329
+ // Cohort Cell Component
330
+ // ============================================================================
331
+
332
+ interface CohortCellProps {
333
+ dataPoint: CohortDataPoint
334
+ row: CohortRow
335
+ periodIndex: number
336
+ colorScale: CohortColorScale
337
+ showAsPercentage: boolean
338
+ decimalPlaces: number
339
+ highlighted: boolean
340
+ onClick?: () => void
341
+ renderCell?: (value: CohortDataPoint, row: CohortRow, periodIndex: number) => React.ReactNode
342
+ renderTooltip?: (value: CohortDataPoint, row: CohortRow, periodIndex: number) => React.ReactNode
343
+ className?: string
344
+ }
345
+
346
+ function CohortCell({
347
+ dataPoint,
348
+ row,
349
+ periodIndex,
350
+ colorScale,
351
+ showAsPercentage,
352
+ decimalPlaces,
353
+ highlighted,
354
+ onClick,
355
+ renderCell,
356
+ renderTooltip,
357
+ className,
358
+ }: CohortCellProps) {
359
+ const [tooltipVisible, setTooltipVisible] = React.useState(false)
360
+ const [tooltipPosition, setTooltipPosition] = React.useState({ x: 0, y: 0 })
361
+ const cellRef = React.useRef<HTMLTableCellElement>(null)
362
+
363
+ const backgroundColor = interpolateColor(dataPoint.value, 0, 100, colorScale)
364
+
365
+ const handleMouseEnter = (e: React.MouseEvent) => {
366
+ setTooltipVisible(true)
367
+ const rect = (e.target as HTMLElement).getBoundingClientRect()
368
+ setTooltipPosition({
369
+ x: rect.left + rect.width / 2,
370
+ y: rect.top,
371
+ })
372
+ }
373
+
374
+ const handleMouseLeave = () => {
375
+ setTooltipVisible(false)
376
+ }
377
+
378
+ const defaultTooltipContent = (
379
+ <div className="space-y-1">
380
+ <div className="font-medium">{row.label}</div>
381
+ <div>Period {periodIndex}: {formatValue(dataPoint.value, showAsPercentage, decimalPlaces)}</div>
382
+ {dataPoint.count !== undefined && (
383
+ <div className="text-muted-foreground">Count: {formatNumber(dataPoint.count)}</div>
384
+ )}
385
+ {dataPoint.metadata && Object.keys(dataPoint.metadata).length > 0 && (
386
+ <div className="pt-1 border-t">
387
+ {Object.entries(dataPoint.metadata).map(([key, value]) => (
388
+ <div key={key} className="text-muted-foreground">
389
+ {key}: {String(value)}
390
+ </div>
391
+ ))}
392
+ </div>
393
+ )}
394
+ </div>
395
+ )
396
+
397
+ return (
398
+ <td
399
+ ref={cellRef}
400
+ className={cn(
401
+ "px-3 py-2 text-center text-sm font-medium transition-all cursor-pointer",
402
+ "border-r border-border last:border-r-0",
403
+ highlighted && "ring-2 ring-primary ring-inset",
404
+ className
405
+ )}
406
+ style={{ backgroundColor }}
407
+ onClick={onClick}
408
+ onMouseEnter={handleMouseEnter}
409
+ onMouseLeave={handleMouseLeave}
410
+ >
411
+ <Tooltip
412
+ content={renderTooltip ? renderTooltip(dataPoint, row, periodIndex) : defaultTooltipContent}
413
+ visible={tooltipVisible}
414
+ position={tooltipPosition}
415
+ >
416
+ {renderCell ? (
417
+ renderCell(dataPoint, row, periodIndex)
418
+ ) : (
419
+ formatValue(dataPoint.value, showAsPercentage, decimalPlaces)
420
+ )}
421
+ </Tooltip>
422
+ </td>
423
+ )
424
+ }
425
+
426
+ // ============================================================================
427
+ // Main Component
428
+ // ============================================================================
429
+
430
+ export function WakaCohortTable({
431
+ data,
432
+ period = "month",
433
+ metric = "retention",
434
+ metricLabel,
435
+ showAsPercentage = true,
436
+ decimalPlaces = 1,
437
+ colorScale = defaultColorScale,
438
+ showCohortSize = true,
439
+ showAverageRow = true,
440
+ showTrends = true,
441
+ highlightOnHover = true,
442
+ showLegend = true,
443
+ enableExport = true,
444
+ onCellClick,
445
+ onExport,
446
+ renderCell,
447
+ renderTooltip,
448
+ className,
449
+ tableClassName,
450
+ headerClassName,
451
+ cellClassName,
452
+ }: WakaCohortTableProps) {
453
+ const [hoveredRow, setHoveredRow] = React.useState<string | null>(null)
454
+ const [hoveredColumn, setHoveredColumn] = React.useState<number | null>(null)
455
+
456
+ // Calculate max periods across all rows
457
+ const maxPeriods = React.useMemo(
458
+ () => Math.max(...data.map((row) => row.data.length), 0),
459
+ [data]
460
+ )
461
+
462
+ // Calculate averages
463
+ const averages = React.useMemo(() => calculateAverages(data), [data])
464
+
465
+ // Handle export
466
+ const handleExport = React.useCallback(
467
+ (format: "csv" | "json") => {
468
+ const label = metricLabel || metric
469
+ const filename = `cohort-${label}-${new Date().toISOString().split("T")[0]}`
470
+
471
+ if (onExport) {
472
+ onExport({ format, filename, includeMetadata: true })
473
+ return
474
+ }
475
+
476
+ let content: string
477
+ let mimeType: string
478
+ let extension: string
479
+
480
+ if (format === "csv") {
481
+ content = exportToCSV(data, period, label)
482
+ mimeType = "text/csv"
483
+ extension = "csv"
484
+ } else {
485
+ content = exportToJSON(data, true)
486
+ mimeType = "application/json"
487
+ extension = "json"
488
+ }
489
+
490
+ const blob = new Blob([content], { type: mimeType })
491
+ const url = URL.createObjectURL(blob)
492
+ const link = document.createElement("a")
493
+ link.href = url
494
+ link.download = `${filename}.${extension}`
495
+ document.body.appendChild(link)
496
+ link.click()
497
+ document.body.removeChild(link)
498
+ URL.revokeObjectURL(url)
499
+ },
500
+ [data, period, metric, metricLabel, onExport]
501
+ )
502
+
503
+ // Handle cell highlighting
504
+ const handleCellMouseEnter = React.useCallback(
505
+ (rowId: string, columnIndex: number) => {
506
+ if (highlightOnHover) {
507
+ setHoveredRow(rowId)
508
+ setHoveredColumn(columnIndex)
509
+ }
510
+ },
511
+ [highlightOnHover]
512
+ )
513
+
514
+ const handleCellMouseLeave = React.useCallback(() => {
515
+ setHoveredRow(null)
516
+ setHoveredColumn(null)
517
+ }, [])
518
+
519
+ // Determine the display label for the metric
520
+ const displayMetricLabel = metricLabel || {
521
+ retention: "Retention",
522
+ churn: "Churn",
523
+ revenue: "Revenue",
524
+ engagement: "Engagement",
525
+ custom: "Value",
526
+ }[metric]
527
+
528
+ if (data.length === 0) {
529
+ return (
530
+ <div className={cn("flex flex-col items-center justify-center py-12 text-center", className)}>
531
+ <Users className="h-12 w-12 text-muted-foreground/50 mb-4" />
532
+ <h3 className="text-lg font-medium text-muted-foreground">No cohort data</h3>
533
+ <p className="text-sm text-muted-foreground/70 mt-1">
534
+ Add cohort data to see the analysis table.
535
+ </p>
536
+ </div>
537
+ )
538
+ }
539
+
540
+ return (
541
+ <div className={cn("space-y-4", className)}>
542
+ {/* Header with legend and export */}
543
+ <div className="flex items-center justify-between flex-wrap gap-4">
544
+ <div className="flex items-center gap-4">
545
+ <h3 className="text-lg font-semibold">{displayMetricLabel} Analysis</h3>
546
+ {showLegend && (
547
+ <ColorLegend
548
+ colorScale={colorScale}
549
+ min={0}
550
+ max={100}
551
+ showAsPercentage={showAsPercentage}
552
+ />
553
+ )}
554
+ </div>
555
+
556
+ {enableExport && (
557
+ <div className="flex items-center gap-2">
558
+ <button
559
+ onClick={() => handleExport("csv")}
560
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
561
+ >
562
+ <Download className="h-4 w-4" />
563
+ CSV
564
+ </button>
565
+ <button
566
+ onClick={() => handleExport("json")}
567
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
568
+ >
569
+ <Download className="h-4 w-4" />
570
+ JSON
571
+ </button>
572
+ </div>
573
+ )}
574
+ </div>
575
+
576
+ {/* Table */}
577
+ <div className="overflow-x-auto rounded-lg border border-border">
578
+ <table className={cn("w-full border-collapse", tableClassName)}>
579
+ <thead>
580
+ <tr className="bg-muted/50">
581
+ {/* Cohort header */}
582
+ <th
583
+ className={cn(
584
+ "px-4 py-3 text-left text-sm font-semibold text-foreground border-b border-r border-border sticky left-0 bg-muted/50 z-10",
585
+ headerClassName
586
+ )}
587
+ >
588
+ Cohort
589
+ </th>
590
+
591
+ {/* Size header */}
592
+ {showCohortSize && (
593
+ <th
594
+ className={cn(
595
+ "px-4 py-3 text-center text-sm font-semibold text-foreground border-b border-r border-border",
596
+ headerClassName
597
+ )}
598
+ >
599
+ <div className="flex items-center justify-center gap-1">
600
+ <Users className="h-4 w-4" />
601
+ Size
602
+ </div>
603
+ </th>
604
+ )}
605
+
606
+ {/* Period headers */}
607
+ {Array.from({ length: maxPeriods }, (_, i) => (
608
+ <th
609
+ key={i}
610
+ className={cn(
611
+ "px-3 py-3 text-center text-sm font-semibold text-foreground border-b border-r border-border last:border-r-0 transition-colors",
612
+ hoveredColumn === i && highlightOnHover && "bg-accent",
613
+ headerClassName
614
+ )}
615
+ >
616
+ {getPeriodLabel(period, i)}
617
+ </th>
618
+ ))}
619
+ </tr>
620
+ </thead>
621
+
622
+ <tbody>
623
+ {data.map((row, rowIndex) => {
624
+ const isHighlightedRow = hoveredRow === row.id
625
+
626
+ return (
627
+ <tr
628
+ key={row.id}
629
+ className={cn(
630
+ "transition-colors",
631
+ isHighlightedRow && highlightOnHover && "bg-accent/30"
632
+ )}
633
+ onMouseEnter={() => setHoveredRow(row.id)}
634
+ onMouseLeave={() => setHoveredRow(null)}
635
+ >
636
+ {/* Cohort label */}
637
+ <td
638
+ className={cn(
639
+ "px-4 py-2 text-sm font-medium text-foreground border-b border-r border-border sticky left-0 bg-background z-10",
640
+ isHighlightedRow && highlightOnHover && "bg-accent/30",
641
+ cellClassName
642
+ )}
643
+ >
644
+ <div className="flex items-center gap-2">
645
+ {row.label}
646
+ {showTrends && rowIndex > 0 && row.data[0] && data[rowIndex - 1].data[0] && (
647
+ <>
648
+ {calculateTrend(row.data[0].value, data[rowIndex - 1].data[0].value) === "up" && (
649
+ <TrendingUp className="h-3 w-3 text-green-600" />
650
+ )}
651
+ {calculateTrend(row.data[0].value, data[rowIndex - 1].data[0].value) === "down" && (
652
+ <TrendingDown className="h-3 w-3 text-red-600" />
653
+ )}
654
+ </>
655
+ )}
656
+ </div>
657
+ </td>
658
+
659
+ {/* Cohort size */}
660
+ {showCohortSize && (
661
+ <td
662
+ className={cn(
663
+ "px-4 py-2 text-center text-sm text-muted-foreground border-b border-r border-border",
664
+ cellClassName
665
+ )}
666
+ >
667
+ {formatNumber(row.size)}
668
+ </td>
669
+ )}
670
+
671
+ {/* Data cells */}
672
+ {Array.from({ length: maxPeriods }, (_, i) => {
673
+ const dataPoint = row.data[i]
674
+
675
+ if (!dataPoint) {
676
+ return (
677
+ <td
678
+ key={i}
679
+ className={cn(
680
+ "px-3 py-2 text-center text-sm text-muted-foreground border-b border-r border-border last:border-r-0 bg-muted/20",
681
+ cellClassName
682
+ )}
683
+ onMouseEnter={() => handleCellMouseEnter(row.id, i)}
684
+ onMouseLeave={handleCellMouseLeave}
685
+ >
686
+ -
687
+ </td>
688
+ )
689
+ }
690
+
691
+ const isHighlighted =
692
+ (isHighlightedRow || hoveredColumn === i) && highlightOnHover
693
+
694
+ return (
695
+ <CohortCell
696
+ key={i}
697
+ dataPoint={dataPoint}
698
+ row={row}
699
+ periodIndex={i}
700
+ colorScale={colorScale}
701
+ showAsPercentage={showAsPercentage}
702
+ decimalPlaces={decimalPlaces}
703
+ highlighted={isHighlighted}
704
+ onClick={() => onCellClick?.(row, i, dataPoint)}
705
+ renderCell={renderCell}
706
+ renderTooltip={renderTooltip}
707
+ className={cn(
708
+ "border-b",
709
+ hoveredColumn === i && highlightOnHover && "ring-2 ring-primary/50 ring-inset",
710
+ cellClassName
711
+ )}
712
+ />
713
+ )
714
+ })}
715
+ </tr>
716
+ )
717
+ })}
718
+
719
+ {/* Average row */}
720
+ {showAverageRow && averages.length > 0 && (
721
+ <tr className="bg-muted/30 font-medium">
722
+ <td
723
+ className={cn(
724
+ "px-4 py-2 text-sm font-semibold text-foreground border-r border-border sticky left-0 bg-muted/30 z-10",
725
+ cellClassName
726
+ )}
727
+ >
728
+ <div className="flex items-center gap-2">
729
+ <Info className="h-4 w-4 text-muted-foreground" />
730
+ Average
731
+ </div>
732
+ </td>
733
+
734
+ {showCohortSize && (
735
+ <td
736
+ className={cn(
737
+ "px-4 py-2 text-center text-sm text-muted-foreground border-r border-border",
738
+ cellClassName
739
+ )}
740
+ >
741
+ {formatNumber(Math.round(data.reduce((sum, row) => sum + row.size, 0) / data.length))}
742
+ </td>
743
+ )}
744
+
745
+ {Array.from({ length: maxPeriods }, (_, i) => {
746
+ const avg = averages[i]
747
+
748
+ if (!avg) {
749
+ return (
750
+ <td
751
+ key={i}
752
+ className={cn(
753
+ "px-3 py-2 text-center text-sm text-muted-foreground border-r border-border last:border-r-0",
754
+ cellClassName
755
+ )}
756
+ >
757
+ -
758
+ </td>
759
+ )
760
+ }
761
+
762
+ return (
763
+ <td
764
+ key={i}
765
+ className={cn(
766
+ "px-3 py-2 text-center text-sm font-semibold border-r border-border last:border-r-0",
767
+ cellClassName
768
+ )}
769
+ style={{
770
+ backgroundColor: interpolateColor(avg.value, 0, 100, colorScale),
771
+ }}
772
+ >
773
+ {formatValue(avg.value, showAsPercentage, decimalPlaces)}
774
+ </td>
775
+ )
776
+ })}
777
+ </tr>
778
+ )}
779
+ </tbody>
780
+ </table>
781
+ </div>
782
+
783
+ {/* Footer info */}
784
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
785
+ <span>
786
+ {data.length} cohort{data.length !== 1 ? "s" : ""} | {maxPeriods} period{maxPeriods !== 1 ? "s" : ""}
787
+ </span>
788
+ <span>
789
+ Total users: {formatNumber(data.reduce((sum, row) => sum + row.size, 0))}
790
+ </span>
791
+ </div>
792
+ </div>
793
+ )
794
+ }
795
+
796
+ // ============================================================================
797
+ // Hook for managing cohort data
798
+ // ============================================================================
799
+
800
+ export interface UseCohortTableOptions {
801
+ /** Initial cohort data */
802
+ initialData?: CohortRow[]
803
+ /** Default period type */
804
+ defaultPeriod?: CohortPeriod
805
+ /** Default metric */
806
+ defaultMetric?: CohortMetric
807
+ }
808
+
809
+ export interface CohortStatistics {
810
+ /** Total number of cohorts */
811
+ totalCohorts: number
812
+ /** Total users across all cohorts */
813
+ totalUsers: number
814
+ /** Average initial cohort size */
815
+ averageCohortSize: number
816
+ /** Average retention at each period */
817
+ averageRetentionByPeriod: number[]
818
+ /** Overall average retention */
819
+ overallAverageRetention: number
820
+ /** Best performing cohort */
821
+ bestCohort: CohortRow | null
822
+ /** Worst performing cohort */
823
+ worstCohort: CohortRow | null
824
+ }
825
+
826
+ export function useCohortTable({
827
+ initialData = [],
828
+ defaultPeriod = "month",
829
+ defaultMetric = "retention",
830
+ }: UseCohortTableOptions = {}) {
831
+ const [data, setData] = React.useState<CohortRow[]>(initialData)
832
+ const [period, setPeriod] = React.useState<CohortPeriod>(defaultPeriod)
833
+ const [metric, setMetric] = React.useState<CohortMetric>(defaultMetric)
834
+ const [selectedCohort, setSelectedCohort] = React.useState<string | null>(null)
835
+
836
+ // Calculate statistics
837
+ const statistics = React.useMemo<CohortStatistics>(() => {
838
+ if (data.length === 0) {
839
+ return {
840
+ totalCohorts: 0,
841
+ totalUsers: 0,
842
+ averageCohortSize: 0,
843
+ averageRetentionByPeriod: [],
844
+ overallAverageRetention: 0,
845
+ bestCohort: null,
846
+ worstCohort: null,
847
+ }
848
+ }
849
+
850
+ const totalUsers = data.reduce((sum, row) => sum + row.size, 0)
851
+ const averageCohortSize = totalUsers / data.length
852
+
853
+ // Calculate average retention by period
854
+ const maxPeriods = Math.max(...data.map((row) => row.data.length))
855
+ const averageRetentionByPeriod = Array.from({ length: maxPeriods }, (_, i) => {
856
+ const values = data
857
+ .map((row) => row.data[i]?.value)
858
+ .filter((v): v is number => v !== undefined)
859
+ return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
860
+ })
861
+
862
+ const allValues = data.flatMap((row) => row.data.map((d) => d.value))
863
+ const overallAverageRetention =
864
+ allValues.length > 0 ? allValues.reduce((a, b) => a + b, 0) / allValues.length : 0
865
+
866
+ // Find best and worst cohorts by average retention
867
+ const cohortAverages = data.map((row) => ({
868
+ row,
869
+ average: row.data.length > 0
870
+ ? row.data.reduce((sum, d) => sum + d.value, 0) / row.data.length
871
+ : 0,
872
+ }))
873
+
874
+ const sorted = [...cohortAverages].sort((a, b) => b.average - a.average)
875
+ const bestCohort = sorted[0]?.row || null
876
+ const worstCohort = sorted[sorted.length - 1]?.row || null
877
+
878
+ return {
879
+ totalCohorts: data.length,
880
+ totalUsers,
881
+ averageCohortSize,
882
+ averageRetentionByPeriod,
883
+ overallAverageRetention,
884
+ bestCohort,
885
+ worstCohort,
886
+ }
887
+ }, [data])
888
+
889
+ // Add a new cohort
890
+ const addCohort = React.useCallback((cohort: CohortRow) => {
891
+ setData((prev) => [...prev, cohort])
892
+ }, [])
893
+
894
+ // Update an existing cohort
895
+ const updateCohort = React.useCallback((id: string, updates: Partial<CohortRow>) => {
896
+ setData((prev) =>
897
+ prev.map((row) => (row.id === id ? { ...row, ...updates } : row))
898
+ )
899
+ }, [])
900
+
901
+ // Remove a cohort
902
+ const removeCohort = React.useCallback((id: string) => {
903
+ setData((prev) => prev.filter((row) => row.id !== id))
904
+ }, [])
905
+
906
+ // Update a specific data point
907
+ const updateDataPoint = React.useCallback(
908
+ (cohortId: string, periodIndex: number, value: Partial<CohortDataPoint>) => {
909
+ setData((prev) =>
910
+ prev.map((row) => {
911
+ if (row.id !== cohortId) return row
912
+ const newData = [...row.data]
913
+ if (newData[periodIndex]) {
914
+ newData[periodIndex] = { ...newData[periodIndex], ...value }
915
+ }
916
+ return { ...row, data: newData }
917
+ })
918
+ )
919
+ },
920
+ []
921
+ )
922
+
923
+ // Sort cohorts
924
+ const sortCohorts = React.useCallback(
925
+ (sortBy: "date" | "size" | "retention", order: "asc" | "desc" = "desc") => {
926
+ setData((prev) => {
927
+ const sorted = [...prev].sort((a, b) => {
928
+ let comparison = 0
929
+
930
+ switch (sortBy) {
931
+ case "date":
932
+ comparison = a.date.getTime() - b.date.getTime()
933
+ break
934
+ case "size":
935
+ comparison = a.size - b.size
936
+ break
937
+ case "retention":
938
+ const avgA = a.data.length > 0
939
+ ? a.data.reduce((sum, d) => sum + d.value, 0) / a.data.length
940
+ : 0
941
+ const avgB = b.data.length > 0
942
+ ? b.data.reduce((sum, d) => sum + d.value, 0) / b.data.length
943
+ : 0
944
+ comparison = avgA - avgB
945
+ break
946
+ }
947
+
948
+ return order === "asc" ? comparison : -comparison
949
+ })
950
+
951
+ return sorted
952
+ })
953
+ },
954
+ []
955
+ )
956
+
957
+ // Filter cohorts by date range
958
+ const filterByDateRange = React.useCallback(
959
+ (startDate: Date, endDate: Date) => {
960
+ return data.filter(
961
+ (row) => row.date >= startDate && row.date <= endDate
962
+ )
963
+ },
964
+ [data]
965
+ )
966
+
967
+ // Get cohort by ID
968
+ const getCohortById = React.useCallback(
969
+ (id: string) => data.find((row) => row.id === id),
970
+ [data]
971
+ )
972
+
973
+ // Reset to initial data
974
+ const reset = React.useCallback(() => {
975
+ setData(initialData)
976
+ setPeriod(defaultPeriod)
977
+ setMetric(defaultMetric)
978
+ setSelectedCohort(null)
979
+ }, [initialData, defaultPeriod, defaultMetric])
980
+
981
+ return {
982
+ // State
983
+ data,
984
+ period,
985
+ metric,
986
+ selectedCohort,
987
+ statistics,
988
+
989
+ // Setters
990
+ setData,
991
+ setPeriod,
992
+ setMetric,
993
+ setSelectedCohort,
994
+
995
+ // Actions
996
+ addCohort,
997
+ updateCohort,
998
+ removeCohort,
999
+ updateDataPoint,
1000
+ sortCohorts,
1001
+ filterByDateRange,
1002
+ getCohortById,
1003
+ reset,
1004
+ }
1005
+ }
1006
+
1007
+ // ============================================================================
1008
+ // Exports
1009
+ // ============================================================================
1010
+
1011
+ export default WakaCohortTable