@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.
- package/dist/cli/commands/add.d.ts +7 -0
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/list.d.ts +5 -0
- package/dist/cli/commands/search.d.ts +1 -0
- package/dist/cli/index.cjs +4844 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/utils/config.d.ts +29 -0
- package/dist/cli/utils/logger.d.ts +20 -0
- package/dist/cli/utils/registry.d.ts +23 -0
- package/package.json +14 -3
- package/src/blocks/activity-timeline/index.tsx +586 -0
- package/src/blocks/calendar-view/index.tsx +756 -0
- package/src/blocks/chat/index.tsx +1018 -0
- package/src/blocks/chat/widget.tsx +504 -0
- package/src/blocks/dashboard/index.tsx +522 -0
- package/src/blocks/empty-states/index.tsx +452 -0
- package/src/blocks/error-pages/index.tsx +426 -0
- package/src/blocks/faq/index.tsx +479 -0
- package/src/blocks/file-manager/index.tsx +890 -0
- package/src/blocks/footer/index.tsx +133 -0
- package/src/blocks/header/index.tsx +357 -0
- package/src/blocks/headtab/index.tsx +139 -0
- package/src/blocks/i18n-editor/index.tsx +1016 -0
- package/src/blocks/index.ts +80 -0
- package/src/blocks/kanban-board/index.tsx +779 -0
- package/src/blocks/landing/index.tsx +677 -0
- package/src/blocks/language-selector/index.tsx +88 -0
- package/src/blocks/layout/index.tsx +159 -0
- package/src/blocks/login/index.tsx +339 -0
- package/src/blocks/login/types.ts +131 -0
- package/src/blocks/pricing/index.tsx +564 -0
- package/src/blocks/profile/index.tsx +746 -0
- package/src/blocks/settings/index.tsx +558 -0
- package/src/blocks/sidebar/index.tsx +713 -0
- package/src/blocks/theme-creator-block/index.tsx +835 -0
- package/src/blocks/user-management/index.tsx +1037 -0
- package/src/blocks/wizard/index.tsx +719 -0
- package/src/components/DataTable/DataTable.tsx +406 -0
- package/src/components/DataTable/DataTableAdvanced.tsx +720 -0
- package/src/components/DataTable/DataTableBody.tsx +216 -0
- package/src/components/DataTable/DataTableCell.tsx +172 -0
- package/src/components/DataTable/DataTableColumnResizer.tsx +62 -0
- package/src/components/DataTable/DataTableConflictResolver.tsx +478 -0
- package/src/components/DataTable/DataTableContextMenu.tsx +219 -0
- package/src/components/DataTable/DataTableEditCell.tsx +279 -0
- package/src/components/DataTable/DataTableFilterBuilder.tsx +519 -0
- package/src/components/DataTable/DataTableFilters.tsx +535 -0
- package/src/components/DataTable/DataTableGrouping.tsx +147 -0
- package/src/components/DataTable/DataTableHeader.tsx +172 -0
- package/src/components/DataTable/DataTablePagination.tsx +125 -0
- package/src/components/DataTable/DataTableSelection.tsx +269 -0
- package/src/components/DataTable/DataTableSyncStatus.tsx +281 -0
- package/src/components/DataTable/DataTableToolbar.tsx +262 -0
- package/src/components/DataTable/README.md +446 -0
- package/src/components/DataTable/__tests__/DataTableAdvanced.test.tsx +426 -0
- package/src/components/DataTable/__tests__/DataTableEdit.test.tsx +329 -0
- package/src/components/DataTable/__tests__/useDataTableAdvanced.test.ts +455 -0
- package/src/components/DataTable/examples/EditExample.tsx +166 -0
- package/src/components/DataTable/formatters/index.ts +335 -0
- package/src/components/DataTable/hooks/__tests__/useDataTableEdit.test.ts +239 -0
- package/src/components/DataTable/hooks/useDataTable.ts +145 -0
- package/src/components/DataTable/hooks/useDataTableAdvanced.ts +342 -0
- package/src/components/DataTable/hooks/useDataTableAdvancedFilters.ts +637 -0
- package/src/components/DataTable/hooks/useDataTableColumnTemplates.ts +186 -0
- package/src/components/DataTable/hooks/useDataTableEdit.ts +167 -0
- package/src/components/DataTable/hooks/useDataTableExport.ts +227 -0
- package/src/components/DataTable/hooks/useDataTableImport.ts +216 -0
- package/src/components/DataTable/hooks/useDataTableOffline.ts +481 -0
- package/src/components/DataTable/hooks/useDataTableTheme.ts +213 -0
- package/src/components/DataTable/hooks/useDataTableVirtualization.ts +99 -0
- package/src/components/DataTable/hooks/useTableLayout.ts +85 -0
- package/src/components/DataTable/index.ts +81 -0
- package/src/components/DataTable/services/IndexedDBService.ts +504 -0
- package/src/components/DataTable/templates/index.tsx +803 -0
- package/src/components/DataTable/types.ts +504 -0
- package/src/components/DataTable/utils.ts +164 -0
- package/src/components/DataTable/workers/exportWorker.ts +213 -0
- package/src/components/accordion/index.tsx +61 -0
- package/src/components/alert/index.tsx +61 -0
- package/src/components/alert-dialog/index.tsx +146 -0
- package/src/components/aspect-ratio/index.tsx +12 -0
- package/src/components/avatar/index.tsx +54 -0
- package/src/components/badge/Badge.stories.tsx +64 -0
- package/src/components/badge/index.tsx +38 -0
- package/src/components/button/Button.stories.tsx +173 -0
- package/src/components/button/index.tsx +56 -0
- package/src/components/calendar/index.tsx +73 -0
- package/src/components/card/index.tsx +78 -0
- package/src/components/checkbox/index.tsx +34 -0
- package/src/components/code/index.tsx +229 -0
- package/src/components/collapsible/index.tsx +16 -0
- package/src/components/command/index.tsx +162 -0
- package/src/components/context-menu/index.tsx +204 -0
- package/src/components/dialog/index.tsx +126 -0
- package/src/components/dropdown-menu/index.tsx +204 -0
- package/src/components/error-boundary/ErrorBoundary.tsx +281 -0
- package/src/components/error-boundary/index.ts +7 -0
- package/src/components/form/index.tsx +183 -0
- package/src/components/hover-card/index.tsx +33 -0
- package/src/components/index.ts +368 -0
- package/src/components/input/Input.stories.tsx +100 -0
- package/src/components/input/index.tsx +27 -0
- package/src/components/input-otp/index.tsx +277 -0
- package/src/components/label/index.tsx +30 -0
- package/src/components/language-selector/index.tsx +341 -0
- package/src/components/menubar/index.tsx +240 -0
- package/src/components/navigation-menu/index.tsx +134 -0
- package/src/components/popover/index.tsx +35 -0
- package/src/components/progress/index.tsx +32 -0
- package/src/components/radio-group/index.tsx +48 -0
- package/src/components/scroll-area/index.tsx +52 -0
- package/src/components/select/index.tsx +164 -0
- package/src/components/separator/index.tsx +35 -0
- package/src/components/sheet/index.tsx +147 -0
- package/src/components/skeleton/index.tsx +22 -0
- package/src/components/slider/index.tsx +32 -0
- package/src/components/switch/index.tsx +33 -0
- package/src/components/table/index.tsx +117 -0
- package/src/components/tabs/index.tsx +59 -0
- package/src/components/textarea/index.tsx +30 -0
- package/src/components/theme-selector/index.tsx +327 -0
- package/src/components/toast/index.tsx +133 -0
- package/src/components/toaster/index.tsx +34 -0
- package/src/components/toggle/index.tsx +49 -0
- package/src/components/tooltip/index.tsx +34 -0
- package/src/components/typography/index.tsx +276 -0
- package/src/components/waka-3d-pie-chart/index.tsx +486 -0
- package/src/components/waka-achievement-unlock/index.tsx +716 -0
- package/src/components/waka-activity-feed/index.tsx +686 -0
- package/src/components/waka-address-autocomplete/index.tsx +1202 -0
- package/src/components/waka-admincrumb/index.tsx +349 -0
- package/src/components/waka-alert-stack/index.tsx +827 -0
- package/src/components/waka-allocation-matrix/index.tsx +1278 -0
- package/src/components/waka-approval-chain/index.tsx +766 -0
- package/src/components/waka-audit-log/index.tsx +1475 -0
- package/src/components/waka-autocomplete/index.tsx +358 -0
- package/src/components/waka-badge-showcase/index.tsx +704 -0
- package/src/components/waka-barcode/index.tsx +260 -0
- package/src/components/waka-biometric-prompt/index.tsx +765 -0
- package/src/components/waka-bottom-sheet/index.tsx +495 -0
- package/src/components/waka-breadcrumb/index.tsx +376 -0
- package/src/components/waka-breadcrumb-path/index.tsx +513 -0
- package/src/components/waka-budget-burn/index.tsx +1234 -0
- package/src/components/waka-capacity-planner/index.tsx +1107 -0
- package/src/components/waka-carousel/index.tsx +893 -0
- package/src/components/waka-cart-summary/index.tsx +1055 -0
- package/src/components/waka-challenge-timer/index.tsx +1044 -0
- package/src/components/waka-charts/WakaAreaChart.tsx +251 -0
- package/src/components/waka-charts/WakaBarChart.tsx +222 -0
- package/src/components/waka-charts/WakaChart.tsx +124 -0
- package/src/components/waka-charts/WakaLineChart.tsx +219 -0
- package/src/components/waka-charts/WakaMiniChart.tsx +133 -0
- package/src/components/waka-charts/WakaPieChart.tsx +214 -0
- package/src/components/waka-charts/WakaSparkline.tsx +229 -0
- package/src/components/waka-charts/dataTableHelpers.ts +109 -0
- package/src/components/waka-charts/hooks/useChartTheme.ts +123 -0
- package/src/components/waka-charts/hooks/useRechartsLoader.ts +234 -0
- package/src/components/waka-charts/index.ts +90 -0
- package/src/components/waka-charts/types.ts +330 -0
- package/src/components/waka-chat-bubble/index.tsx +1060 -0
- package/src/components/waka-checklist/index.tsx +1067 -0
- package/src/components/waka-checkout-stepper/index.tsx +976 -0
- package/src/components/waka-cohort-table/index.tsx +1011 -0
- package/src/components/waka-color-picker/index.tsx +447 -0
- package/src/components/waka-combo-counter/index.tsx +864 -0
- package/src/components/waka-combobox/index.tsx +497 -0
- package/src/components/waka-command-bar/index.tsx +403 -0
- package/src/components/waka-compare-period/index.tsx +1230 -0
- package/src/components/waka-connection-matrix/index.tsx +1053 -0
- package/src/components/waka-contribution-graph/index.tsx +552 -0
- package/src/components/waka-cost-breakdown/index.tsx +1065 -0
- package/src/components/waka-coupon-input/index.tsx +592 -0
- package/src/components/waka-credit-card-input/index.tsx +982 -0
- package/src/components/waka-daily-reward/index.tsx +762 -0
- package/src/components/waka-date-range-picker/index.tsx +378 -0
- package/src/components/waka-datetime-picker/index.tsx +793 -0
- package/src/components/waka-datetime-picker.form-integration/index.tsx +402 -0
- package/src/components/waka-deployment-lane/index.tsx +673 -0
- package/src/components/waka-device-trust/index.tsx +1259 -0
- package/src/components/waka-dock/index.tsx +285 -0
- package/src/components/waka-drawer/index.tsx +319 -0
- package/src/components/waka-empty-state/index.tsx +545 -0
- package/src/components/waka-error-shake/index.tsx +398 -0
- package/src/components/waka-feature-announcement/index.tsx +991 -0
- package/src/components/waka-file-upload/index.tsx +437 -0
- package/src/components/waka-floating-nav/index.tsx +413 -0
- package/src/components/waka-flow-diagram/index.tsx +508 -0
- package/src/components/waka-funnel-chart/index.tsx +823 -0
- package/src/components/waka-glow-card/index.tsx +246 -0
- package/src/components/waka-goal-progress/index.tsx +1025 -0
- package/src/components/waka-haptic-button/index.tsx +388 -0
- package/src/components/waka-health-pulse/index.tsx +451 -0
- package/src/components/waka-heatmap/index.tsx +1026 -0
- package/src/components/waka-hotspot/index.tsx +682 -0
- package/src/components/waka-image/index.tsx +373 -0
- package/src/components/waka-incident-timeline/index.tsx +686 -0
- package/src/components/waka-invoice-preview/index.tsx +829 -0
- package/src/components/waka-kanban/index.tsx +646 -0
- package/src/components/waka-kpi-dashboard/index.tsx +755 -0
- package/src/components/waka-leaderboard/index.tsx +746 -0
- package/src/components/waka-level-progress/index.tsx +665 -0
- package/src/components/waka-liquid-button/index.tsx +520 -0
- package/src/components/waka-loading-orbit/index.tsx +478 -0
- package/src/components/waka-loot-box/index.tsx +1091 -0
- package/src/components/waka-magic-link/index.tsx +321 -0
- package/src/components/waka-magnetic-button/index.tsx +567 -0
- package/src/components/waka-mention-input/index.tsx +953 -0
- package/src/components/waka-metric-sparkline/index.tsx +627 -0
- package/src/components/waka-milestone-road/index.tsx +1064 -0
- package/src/components/waka-modal/index.tsx +374 -0
- package/src/components/waka-morph-button/index.tsx +495 -0
- package/src/components/waka-network-topology/index.tsx +801 -0
- package/src/components/waka-notifications/index.tsx +414 -0
- package/src/components/waka-number-input/index.tsx +373 -0
- package/src/components/waka-orbital-menu/index.tsx +445 -0
- package/src/components/waka-order-tracker/index.tsx +1041 -0
- package/src/components/waka-pagination/index.tsx +393 -0
- package/src/components/waka-password-strength/index.tsx +824 -0
- package/src/components/waka-payment-method-picker/index.tsx +715 -0
- package/src/components/waka-permission-matrix/index.tsx +1302 -0
- package/src/components/waka-phone-input/index.tsx +801 -0
- package/src/components/waka-pipeline-view/index.tsx +604 -0
- package/src/components/waka-player-card/index.tsx +691 -0
- package/src/components/waka-points-popup/index.tsx +366 -0
- package/src/components/waka-power-up/index.tsx +1155 -0
- package/src/components/waka-presence-indicator/index.tsx +1181 -0
- package/src/components/waka-pricing-table/index.tsx +755 -0
- package/src/components/waka-product-card/index.tsx +786 -0
- package/src/components/waka-progress-onboarding/index.tsx +878 -0
- package/src/components/waka-pull-to-refresh/index.tsx +451 -0
- package/src/components/waka-qrcode/index.tsx +232 -0
- package/src/components/waka-quest-card/index.tsx +1275 -0
- package/src/components/waka-quota-bar/index.tsx +693 -0
- package/src/components/waka-radar-score/index.tsx +512 -0
- package/src/components/waka-rank-badge/index.tsx +813 -0
- package/src/components/waka-rating-input/index.tsx +560 -0
- package/src/components/waka-reaction-picker/index.tsx +1062 -0
- package/src/components/waka-region-map/index.tsx +730 -0
- package/src/components/waka-resource-gauge/index.tsx +654 -0
- package/src/components/waka-resource-pool/index.tsx +1035 -0
- package/src/components/waka-rich-text-editor/index.tsx +594 -0
- package/src/components/waka-rollback-slider/index.tsx +891 -0
- package/src/components/waka-sankey-diagram/index.tsx +1032 -0
- package/src/components/waka-schedule-picker/index.tsx +1060 -0
- package/src/components/waka-scratch-card/index.tsx +914 -0
- package/src/components/waka-season-pass/index.tsx +886 -0
- package/src/components/waka-security-score/index.tsx +1126 -0
- package/src/components/waka-segmented-control/index.tsx +238 -0
- package/src/components/waka-server-rack/index.tsx +764 -0
- package/src/components/waka-session-manager/index.tsx +815 -0
- package/src/components/waka-signature-pad/index.tsx +744 -0
- package/src/components/waka-skeleton-wave/index.tsx +454 -0
- package/src/components/waka-skill-tree/index.tsx +1031 -0
- package/src/components/waka-sla-tracker/index.tsx +798 -0
- package/src/components/waka-slider-range/index.tsx +765 -0
- package/src/components/waka-spin-wheel/index.tsx +671 -0
- package/src/components/waka-spinner/index.tsx +284 -0
- package/src/components/waka-spotlight/index.tsx +410 -0
- package/src/components/waka-stat/index.tsx +428 -0
- package/src/components/waka-stats-hexagon/index.tsx +824 -0
- package/src/components/waka-status-matrix/index.tsx +565 -0
- package/src/components/waka-stepper/index.tsx +489 -0
- package/src/components/waka-streak-counter/index.tsx +334 -0
- package/src/components/waka-success-explosion/index.tsx +453 -0
- package/src/components/waka-swipe-card/index.tsx +574 -0
- package/src/components/waka-tabs-morph/index.tsx +509 -0
- package/src/components/waka-tag-input/index.tsx +877 -0
- package/src/components/waka-team-banner/index.tsx +1183 -0
- package/src/components/waka-terminal-output/index.tsx +836 -0
- package/src/components/waka-theme-creator/index.tsx +762 -0
- package/src/components/waka-theme-manager/index.tsx +654 -0
- package/src/components/waka-thread-view/index.tsx +874 -0
- package/src/components/waka-tilt-card/index.tsx +250 -0
- package/src/components/waka-time-picker/index.tsx +479 -0
- package/src/components/waka-timeline/index.tsx +385 -0
- package/src/components/waka-tooltip-tour/index.tsx +855 -0
- package/src/components/waka-tour-guide/index.tsx +920 -0
- package/src/components/waka-tournament-bracket/index.tsx +1276 -0
- package/src/components/waka-tree/index.tsx +557 -0
- package/src/components/waka-treemap-chart/index.tsx +1031 -0
- package/src/components/waka-two-factor-setup/index.tsx +995 -0
- package/src/components/waka-typewriter/index.tsx +566 -0
- package/src/components/waka-typing-indicator/index.tsx +649 -0
- package/src/components/waka-versus-card/index.tsx +1026 -0
- package/src/components/waka-video/index.tsx +557 -0
- package/src/components/waka-video-call/index.tsx +1087 -0
- package/src/components/waka-virtual-list/index.tsx +327 -0
- package/src/components/waka-voice-message/index.tsx +1019 -0
- package/src/components/waka-welcome-modal/index.tsx +790 -0
- 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
|