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