From d2152a475d89bb9c5fcf7747c1f646a18e92e3d1 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 12:34:50 +0100 Subject: [PATCH 1/9] refactor: extract standalone packages and add Xcode project - Move ClaudeUsageCore, ClaudeUsageData, ClaudeUsageUI to Packages/ - Add standalone Package.swift for each package - Add Xcode project with local sources for sandboxed app - Disable sandbox to allow access to ~/.claude directory - Fix Swift 6 concurrency: make SystemClock nonisolated - Add missing ClaudeUsageCore import in AnalyticsRows - Use realHomeDirectory() to access actual user home in sandbox --- ClaudeCodeUsage.xcodeproj/project.pbxproj | 643 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../AppLifecycleManager.swift | 0 .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 58 ++ ClaudeCodeUsage/Assets.xcassets/Contents.json | 6 + ClaudeCodeUsage/ClaudeCodeUsage.entitlements | 8 + ClaudeCodeUsage/ClaudeCodeUsageApp.swift | 93 +++ .../MainWindow/Analytics/AnalyticsRows.swift | 105 +++ .../MainWindow/Analytics/AnalyticsView.swift | 0 .../Analytics/Cards/AnalyticsCard.swift | 0 .../Analytics/Cards/PredictionsCard.swift | 0 .../Cards/TokenDistributionCard.swift | 0 .../Analytics/Cards/UsageTrendsCard.swift | 0 .../Cards/YearlyCostHeatmapCard.swift | 0 .../HeatmapConfiguration+Accessibility.swift | 0 .../HeatmapConfiguration+ColorThemes.swift | 0 .../Configuration/HeatmapConfiguration.swift | 0 .../Analytics/Heatmap/Grid/DaySquare.swift | 0 .../Analytics/Heatmap/Grid/HeatmapGrid.swift | 0 .../Heatmap/Grid/HeatmapGridLayout.swift | 0 .../Heatmap/Grid/HeatmapGridPerformance.swift | 0 .../Analytics/Heatmap/Grid/WeekColumn.swift | 0 .../Legend/HeatmapLegend+Factory.swift | 0 .../Heatmap/Legend/HeatmapLegend.swift | 0 .../Heatmap/Legend/HeatmapLegendBuilder.swift | 0 .../Heatmap/Legend/LegendSquare.swift | 0 .../Heatmap/Models/ActivityLevel.swift | 0 .../Heatmap/Models/ActivityLevelLabels.swift | 0 .../Heatmap/Models/BorderStyle.swift | 0 .../Heatmap/Models/ColorScheme.swift | 0 .../Heatmap/Models/DateConstants.swift | 0 .../Heatmap/Models/DateRangeValidation.swift | 0 .../Heatmap/Models/HeatmapData.swift | 0 .../Models/HeatmapDateCalculator.swift | 0 .../Heatmap/Models/MonthOperations.swift | 0 .../Heatmap/Models/WeekOperations.swift | 0 .../Stores/HeatmapStore+DataGeneration.swift | 0 .../Stores/HeatmapStore+SupportingTypes.swift | 0 .../Heatmap/Stores/HeatmapStore.swift | 0 .../Tooltip/HeatmapTooltip+Factory.swift | 0 .../Heatmap/Tooltip/HeatmapTooltip.swift | 0 .../Tooltip/HeatmapTooltipBuilder.swift | 0 .../Tooltip/TooltipConfiguration.swift | 0 .../Heatmap/Tooltip/TooltipPositioning.swift | 0 .../Heatmap/YearlyCostHeatmap+Factories.swift | 0 .../Heatmap/YearlyCostHeatmap+Preview.swift | 0 .../Analytics/Heatmap/YearlyCostHeatmap.swift | 0 .../Components/EmptyStateView.swift | 0 .../MainWindow/Daily/DailyUsageView.swift | 0 .../MainWindow/MainView.swift | 0 .../MainWindow/Models/ModelsView.swift | 0 .../MainWindow/Overview/MetricCard.swift | 0 .../MainWindow/Overview/OverviewView.swift | 0 .../MenuBar/Components/ActionButtons.swift | 0 .../MenuBar/Components/GraphView.swift | 0 .../MenuBar/Components/MetricRow.swift | 0 .../MenuBar/Components/ProgressBar.swift | 0 .../MenuBar/Components/SectionHeader.swift | 0 .../MenuBar/Components/SettingsMenu.swift | 0 .../MenuBar/Helpers/ColorService.swift | 0 .../MenuBar/Helpers/FormatterService.swift | 0 .../MenuBar/MenuBarContentView.swift | 0 .../MenuBar/MenuBarScene.swift | 0 .../MenuBar/Sections/CostMetricsSection.swift | 0 .../Sections/SessionMetricsSection.swift | 0 .../Sections/UsageMetricsSection.swift | 0 .../MenuBar/Theme/MenuBarStyles.swift | 0 .../MenuBar/Theme/MenuBarTheme.swift | 0 .../Settings/AppSettingsService.swift | 0 .../Settings/OpenAtLoginToggle.swift | 0 .../HourlyChart/HourlyChartModels.swift | 0 .../HourlyChart/HourlyCostChartSimple.swift | 0 .../HourlyChart/HourlyTooltipViews.swift | 0 .../Stores/Services/Clock/ClockProtocol.swift | 0 .../Stores/Services/Clock/SystemClock.swift | 24 + .../Stores/Services/Clock/TestClock.swift | 0 .../Services/Loading/AppConfiguration.swift | 12 +- .../Stores/Services/Loading/LoadTrace.swift | 0 .../Loading/SessionMonitorService.swift | 0 .../Services/Loading/UsageDataLoader.swift | 0 .../Stores/Services/RefreshCoordinator.swift | 9 +- .../Stores/UsageStore.swift | 0 .../ClaudeCodeUsageTests.swift | 17 + .../ClaudeCodeUsageUITests.swift | 41 ++ .../ClaudeCodeUsageUITestsLaunchTests.swift | 33 + Package.swift | 82 +-- Packages/ClaudeUsageCore/Package.swift | 24 + .../Analytics/PricingCalculator.swift | 0 .../Analytics/UsageAggregator.swift | 0 .../Analytics/UsageAnalytics.swift | 0 .../ClaudeUsageCore/ClaudeUsageCore.swift | 0 .../ClaudeUsageCore/Models/BurnRate.swift | 0 .../ClaudeUsageCore/Models/SessionBlock.swift | 0 .../ClaudeUsageCore/Models/TokenCounts.swift | 0 .../ClaudeUsageCore/Models/UsageEntry.swift | 0 .../ClaudeUsageCore/Models/UsageStats.swift | 0 .../Protocols/UsageDataSource.swift | 0 .../PricingCalculatorTests.swift | 0 .../TokenCountsTests.swift | 0 .../UsageAggregatorTests.swift | 0 Packages/ClaudeUsageData/Package.swift | 27 + .../ClaudeUsageData/ClaudeUsageData.swift | 0 .../Monitoring/DirectoryMonitor.swift | 0 .../Monitoring/SessionMonitor.swift | 0 .../ClaudeUsageData/Parsing/JSONLParser.swift | 0 .../Repository/FileDiscovery.swift | 0 .../Repository/UsageRepository.swift | 0 .../FileDiscoveryTests.swift | 0 .../JSONLParserTests.swift | 0 .../SessionMonitorTests.swift | 0 .../UsageRepositoryTests.swift | 0 Packages/ClaudeUsageUI/Package.swift | 45 ++ .../App/ClaudeCodeUsageApp.swift | 0 .../ClaudeUsageUI/AppLifecycleManager.swift | 53 ++ .../MainWindow/Analytics/AnalyticsRows.swift | 0 .../MainWindow/Analytics/AnalyticsView.swift | 107 +++ .../Analytics/Cards/AnalyticsCard.swift | 33 + .../Analytics/Cards/PredictionsCard.swift | 58 ++ .../Cards/TokenDistributionCard.swift | 46 ++ .../Analytics/Cards/UsageTrendsCard.swift | 115 ++++ .../Cards/YearlyCostHeatmapCard.swift | 79 +++ .../HeatmapConfiguration+Accessibility.swift | 81 +++ .../HeatmapConfiguration+ColorThemes.swift | 172 +++++ .../Configuration/HeatmapConfiguration.swift | 205 ++++++ .../Analytics/Heatmap/Grid/DaySquare.swift | 98 +++ .../Analytics/Heatmap/Grid/HeatmapGrid.swift | 298 ++++++++ .../Heatmap/Grid/HeatmapGridLayout.swift | 53 ++ .../Heatmap/Grid/HeatmapGridPerformance.swift | 53 ++ .../Analytics/Heatmap/Grid/WeekColumn.swift | 29 + .../Legend/HeatmapLegend+Factory.swift | 60 ++ .../Heatmap/Legend/HeatmapLegend.swift | 297 ++++++++ .../Heatmap/Legend/HeatmapLegendBuilder.swift | 83 +++ .../Heatmap/Legend/LegendSquare.swift | 44 ++ .../Heatmap/Models/ActivityLevel.swift | 43 ++ .../Heatmap/Models/ActivityLevelLabels.swift | 22 + .../Heatmap/Models/BorderStyle.swift | 25 + .../Heatmap/Models/ColorScheme.swift | 352 ++++++++++ .../Heatmap/Models/DateConstants.swift | 13 + .../Heatmap/Models/DateRangeValidation.swift | 66 ++ .../Heatmap/Models/HeatmapData.swift | 347 ++++++++++ .../Models/HeatmapDateCalculator.swift | 301 ++++++++ .../Heatmap/Models/MonthOperations.swift | 40 ++ .../Heatmap/Models/WeekOperations.swift | 56 ++ .../Stores/HeatmapStore+DataGeneration.swift | 238 +++++++ .../Stores/HeatmapStore+SupportingTypes.swift | 98 +++ .../Heatmap/Stores/HeatmapStore.swift | 328 +++++++++ .../Tooltip/HeatmapTooltip+Factory.swift | 39 ++ .../Heatmap/Tooltip/HeatmapTooltip.swift | 373 ++++++++++ .../Tooltip/HeatmapTooltipBuilder.swift | 66 ++ .../Tooltip/TooltipConfiguration.swift | 68 ++ .../Heatmap/Tooltip/TooltipPositioning.swift | 61 ++ .../Heatmap/YearlyCostHeatmap+Factories.swift | 155 +++++ .../Heatmap/YearlyCostHeatmap+Preview.swift | 105 +++ .../Analytics/Heatmap/YearlyCostHeatmap.swift | 292 ++++++++ .../Components/EmptyStateView.swift | 43 ++ .../MainWindow/Daily/DailyUsageView.swift | 303 +++++++++ .../ClaudeUsageUI/MainWindow/MainView.swift | 149 ++++ .../MainWindow/Models/ModelsView.swift | 254 +++++++ .../MainWindow/Overview/MetricCard.swift | 30 + .../MainWindow/Overview/OverviewView.swift | 229 +++++++ .../MenuBar/Components/ActionButtons.swift | 134 ++++ .../MenuBar/Components/GraphView.swift | 244 +++++++ .../MenuBar/Components/MetricRow.swift | 147 ++++ .../MenuBar/Components/ProgressBar.swift | 92 +++ .../MenuBar/Components/SectionHeader.swift | 47 ++ .../MenuBar/Components/SettingsMenu.swift | 85 +++ .../MenuBar/Helpers/ColorService.swift | 122 ++++ .../MenuBar/Helpers/FormatterService.swift | 149 ++++ .../MenuBar/MenuBarContentView.swift | 158 +++++ .../ClaudeUsageUI/MenuBar/MenuBarScene.swift | 282 ++++++++ .../MenuBar/Sections/CostMetricsSection.swift | 169 +++++ .../Sections/SessionMetricsSection.swift | 31 + .../Sections/UsageMetricsSection.swift | 72 ++ .../MenuBar/Theme/MenuBarStyles.swift | 61 ++ .../MenuBar/Theme/MenuBarTheme.swift | 167 +++++ .../Settings/AppSettingsService.swift | 151 ++++ .../Settings/OpenAtLoginToggle.swift | 96 +++ .../HourlyChart/HourlyChartModels.swift | 118 ++++ .../HourlyChart/HourlyCostChartSimple.swift | 250 +++++++ .../HourlyChart/HourlyTooltipViews.swift | 75 ++ .../Stores/Services/Clock/ClockProtocol.swift | 123 ++++ .../Stores/Services/Clock/SystemClock.swift | 0 .../Stores/Services/Clock/TestClock.swift | 104 +++ .../Services/Loading/AppConfiguration.swift | 51 ++ .../Stores/Services/Loading/LoadTrace.swift | 176 +++++ .../Loading/SessionMonitorService.swift | 76 +++ .../Services/Loading/UsageDataLoader.swift | 142 ++++ .../Stores/Services/RefreshCoordinator.swift | 193 ++++++ .../ClaudeUsageUI/Stores/UsageStore.swift | 256 +++++++ .../HeatmapStoreTests.swift | 0 .../UsageDataLoaderTests.swift | 0 .../Core/ClaudeUsageCoreWrapper.swift | 1 + .../Data/ClaudeUsageDataWrapper.swift | 1 + .../Wrappers/UI/ClaudeUsageUIWrapper.swift | 1 + 195 files changed, 11019 insertions(+), 60 deletions(-) create mode 100644 ClaudeCodeUsage.xcodeproj/project.pbxproj create mode 100644 ClaudeCodeUsage.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/AppLifecycleManager.swift (100%) create mode 100644 ClaudeCodeUsage/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 ClaudeCodeUsage/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ClaudeCodeUsage/Assets.xcassets/Contents.json create mode 100644 ClaudeCodeUsage/ClaudeCodeUsage.entitlements create mode 100644 ClaudeCodeUsage/ClaudeCodeUsageApp.swift create mode 100644 ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/AnalyticsView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Cards/AnalyticsCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Cards/PredictionsCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Cards/TokenDistributionCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Cards/UsageTrendsCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/DateConstants.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Components/EmptyStateView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Daily/DailyUsageView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/MainView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Models/ModelsView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Overview/MetricCard.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MainWindow/Overview/OverviewView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/ActionButtons.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/GraphView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/MetricRow.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/ProgressBar.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/SectionHeader.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Components/SettingsMenu.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Helpers/ColorService.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Helpers/FormatterService.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/MenuBarContentView.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/MenuBarScene.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Sections/CostMetricsSection.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Sections/SessionMetricsSection.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Sections/UsageMetricsSection.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Theme/MenuBarStyles.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/MenuBar/Theme/MenuBarTheme.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Settings/AppSettingsService.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Settings/OpenAtLoginToggle.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Shared/Charts/HourlyChart/HourlyChartModels.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Shared/Charts/HourlyChart/HourlyTooltipViews.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Clock/ClockProtocol.swift (100%) create mode 100644 ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Clock/TestClock.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Loading/AppConfiguration.swift (66%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Loading/LoadTrace.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Loading/SessionMonitorService.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/Loading/UsageDataLoader.swift (100%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/Services/RefreshCoordinator.swift (94%) rename {Sources/ClaudeUsageUI => ClaudeCodeUsage}/Stores/UsageStore.swift (100%) create mode 100644 ClaudeCodeUsageTests/ClaudeCodeUsageTests.swift create mode 100644 ClaudeCodeUsageUITests/ClaudeCodeUsageUITests.swift create mode 100644 ClaudeCodeUsageUITests/ClaudeCodeUsageUITestsLaunchTests.swift create mode 100644 Packages/ClaudeUsageCore/Package.swift rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Analytics/PricingCalculator.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Analytics/UsageAggregator.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Analytics/UsageAnalytics.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/ClaudeUsageCore.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Models/BurnRate.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Models/SessionBlock.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Models/TokenCounts.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Models/UsageEntry.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Models/UsageStats.swift (100%) rename {Sources => Packages/ClaudeUsageCore/Sources}/ClaudeUsageCore/Protocols/UsageDataSource.swift (100%) rename {Tests => Packages/ClaudeUsageCore/Tests}/ClaudeUsageCoreTests/PricingCalculatorTests.swift (100%) rename {Tests => Packages/ClaudeUsageCore/Tests}/ClaudeUsageCoreTests/TokenCountsTests.swift (100%) rename {Tests => Packages/ClaudeUsageCore/Tests}/ClaudeUsageCoreTests/UsageAggregatorTests.swift (100%) create mode 100644 Packages/ClaudeUsageData/Package.swift rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/ClaudeUsageData.swift (100%) rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/Monitoring/DirectoryMonitor.swift (100%) rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/Monitoring/SessionMonitor.swift (100%) rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/Parsing/JSONLParser.swift (100%) rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/Repository/FileDiscovery.swift (100%) rename {Sources => Packages/ClaudeUsageData/Sources}/ClaudeUsageData/Repository/UsageRepository.swift (100%) rename {Tests => Packages/ClaudeUsageData/Tests}/ClaudeUsageDataTests/FileDiscoveryTests.swift (100%) rename {Tests => Packages/ClaudeUsageData/Tests}/ClaudeUsageDataTests/JSONLParserTests.swift (100%) rename {Tests => Packages/ClaudeUsageData/Tests}/ClaudeUsageDataTests/SessionMonitorTests.swift (100%) rename {Tests => Packages/ClaudeUsageData/Tests}/ClaudeUsageDataTests/UsageRepositoryTests.swift (100%) create mode 100644 Packages/ClaudeUsageUI/Package.swift rename {Sources/ClaudeUsage => Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage}/App/ClaudeCodeUsageApp.swift (100%) create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift rename {Sources => Packages/ClaudeUsageUI/Sources}/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift (100%) create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift rename {Sources => Packages/ClaudeUsageUI/Sources}/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift (100%) create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift create mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift rename {Tests/ClaudeUsageTests => Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests}/HeatmapStoreTests.swift (100%) rename {Tests/ClaudeUsageTests => Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests}/UsageDataLoaderTests.swift (100%) create mode 100644 Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift create mode 100644 Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift create mode 100644 Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift diff --git a/ClaudeCodeUsage.xcodeproj/project.pbxproj b/ClaudeCodeUsage.xcodeproj/project.pbxproj new file mode 100644 index 0000000..6a080a4 --- /dev/null +++ b/ClaudeCodeUsage.xcodeproj/project.pbxproj @@ -0,0 +1,643 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + ECF410572F03E6FD00DFC0C8 /* ClaudeUsageCore in Frameworks */ = {isa = PBXBuildFile; productRef = ECF410562F03E6FD00DFC0C8 /* ClaudeUsageCore */; }; + ECF410592F03E6FD00DFC0C8 /* ClaudeUsageData in Frameworks */ = {isa = PBXBuildFile; productRef = ECF410582F03E6FD00DFC0C8 /* ClaudeUsageData */; }; + ECF4105C2F03EA3E00DFC0C8 /* ClaudeUsageCore in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4105B2F03EA3E00DFC0C8 /* ClaudeUsageCore */; }; + ECF4105F2F03EA5900DFC0C8 /* ClaudeUsageData in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4105E2F03EA5900DFC0C8 /* ClaudeUsageData */; }; + ECF4108D2F03EC6700DFC0C8 /* ClaudeUsageCore in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4108C2F03EC6700DFC0C8 /* ClaudeUsageCore */; }; + ECF410902F03EC7800DFC0C8 /* ClaudeUsageData in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4108F2F03EC7800DFC0C8 /* ClaudeUsageData */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + ECF410392F03E6AE00DFC0C8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ECF410232F03E6AD00DFC0C8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ECF4102A2F03E6AD00DFC0C8; + remoteInfo = ClaudeCodeUsage; + }; + ECF410432F03E6AE00DFC0C8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = ECF410232F03E6AD00DFC0C8 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ECF4102A2F03E6AD00DFC0C8; + remoteInfo = ClaudeCodeUsage; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + ECF4102B2F03E6AD00DFC0C8 /* ClaudeCodeUsage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ClaudeCodeUsage.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ECF410382F03E6AE00DFC0C8 /* ClaudeCodeUsageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaudeCodeUsageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ECF410422F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaudeCodeUsageUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + ECF4102D2F03E6AD00DFC0C8 /* ClaudeCodeUsage */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ClaudeCodeUsage; + sourceTree = ""; + }; + ECF4103B2F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ClaudeCodeUsageTests; + sourceTree = ""; + }; + ECF410452F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ClaudeCodeUsageUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + ECF410282F03E6AD00DFC0C8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ECF4105F2F03EA5900DFC0C8 /* ClaudeUsageData in Frameworks */, + ECF410592F03E6FD00DFC0C8 /* ClaudeUsageData in Frameworks */, + ECF410572F03E6FD00DFC0C8 /* ClaudeUsageCore in Frameworks */, + ECF410902F03EC7800DFC0C8 /* ClaudeUsageData in Frameworks */, + ECF4105C2F03EA3E00DFC0C8 /* ClaudeUsageCore in Frameworks */, + ECF4108D2F03EC6700DFC0C8 /* ClaudeUsageCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF410352F03E6AE00DFC0C8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF4103F2F03E6AE00DFC0C8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + ECF410222F03E6AD00DFC0C8 = { + isa = PBXGroup; + children = ( + ECF4102D2F03E6AD00DFC0C8 /* ClaudeCodeUsage */, + ECF4103B2F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */, + ECF410452F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */, + ECF4102C2F03E6AD00DFC0C8 /* Products */, + ); + sourceTree = ""; + }; + ECF4102C2F03E6AD00DFC0C8 /* Products */ = { + isa = PBXGroup; + children = ( + ECF4102B2F03E6AD00DFC0C8 /* ClaudeCodeUsage.app */, + ECF410382F03E6AE00DFC0C8 /* ClaudeCodeUsageTests.xctest */, + ECF410422F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + ECF4102A2F03E6AD00DFC0C8 /* ClaudeCodeUsage */ = { + isa = PBXNativeTarget; + buildConfigurationList = ECF4104C2F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsage" */; + buildPhases = ( + ECF410272F03E6AD00DFC0C8 /* Sources */, + ECF410282F03E6AD00DFC0C8 /* Frameworks */, + ECF410292F03E6AD00DFC0C8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + ECF4102D2F03E6AD00DFC0C8 /* ClaudeCodeUsage */, + ); + name = ClaudeCodeUsage; + packageProductDependencies = ( + ECF410562F03E6FD00DFC0C8 /* ClaudeUsageCore */, + ECF410582F03E6FD00DFC0C8 /* ClaudeUsageData */, + ECF4105B2F03EA3E00DFC0C8 /* ClaudeUsageCore */, + ECF4105E2F03EA5900DFC0C8 /* ClaudeUsageData */, + ECF4108C2F03EC6700DFC0C8 /* ClaudeUsageCore */, + ECF4108F2F03EC7800DFC0C8 /* ClaudeUsageData */, + ); + productName = ClaudeCodeUsage; + productReference = ECF4102B2F03E6AD00DFC0C8 /* ClaudeCodeUsage.app */; + productType = "com.apple.product-type.application"; + }; + ECF410372F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = ECF4104F2F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsageTests" */; + buildPhases = ( + ECF410342F03E6AE00DFC0C8 /* Sources */, + ECF410352F03E6AE00DFC0C8 /* Frameworks */, + ECF410362F03E6AE00DFC0C8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ECF4103A2F03E6AE00DFC0C8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + ECF4103B2F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */, + ); + name = ClaudeCodeUsageTests; + packageProductDependencies = ( + ); + productName = ClaudeCodeUsageTests; + productReference = ECF410382F03E6AE00DFC0C8 /* ClaudeCodeUsageTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + ECF410412F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = ECF410522F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsageUITests" */; + buildPhases = ( + ECF4103E2F03E6AE00DFC0C8 /* Sources */, + ECF4103F2F03E6AE00DFC0C8 /* Frameworks */, + ECF410402F03E6AE00DFC0C8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ECF410442F03E6AE00DFC0C8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + ECF410452F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */, + ); + name = ClaudeCodeUsageUITests; + packageProductDependencies = ( + ); + productName = ClaudeCodeUsageUITests; + productReference = ECF410422F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + ECF410232F03E6AD00DFC0C8 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2610; + TargetAttributes = { + ECF4102A2F03E6AD00DFC0C8 = { + CreatedOnToolsVersion = 26.1.1; + }; + ECF410372F03E6AE00DFC0C8 = { + CreatedOnToolsVersion = 26.1.1; + TestTargetID = ECF4102A2F03E6AD00DFC0C8; + }; + ECF410412F03E6AE00DFC0C8 = { + CreatedOnToolsVersion = 26.1.1; + TestTargetID = ECF4102A2F03E6AD00DFC0C8; + }; + }; + }; + buildConfigurationList = ECF410262F03E6AD00DFC0C8 /* Build configuration list for PBXProject "ClaudeCodeUsage" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = ECF410222F03E6AD00DFC0C8; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ECF4108B2F03EC6700DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageCore" */, + ECF4108E2F03EC7800DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageData" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = ECF4102C2F03E6AD00DFC0C8 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + ECF4102A2F03E6AD00DFC0C8 /* ClaudeCodeUsage */, + ECF410372F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */, + ECF410412F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + ECF410292F03E6AD00DFC0C8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF410362F03E6AE00DFC0C8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF410402F03E6AE00DFC0C8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + ECF410272F03E6AD00DFC0C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF410342F03E6AE00DFC0C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ECF4103E2F03E6AE00DFC0C8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + ECF4103A2F03E6AE00DFC0C8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ECF4102A2F03E6AD00DFC0C8 /* ClaudeCodeUsage */; + targetProxy = ECF410392F03E6AE00DFC0C8 /* PBXContainerItemProxy */; + }; + ECF410442F03E6AE00DFC0C8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ECF4102A2F03E6AD00DFC0C8 /* ClaudeCodeUsage */; + targetProxy = ECF410432F03E6AE00DFC0C8 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + ECF4104A2F03E6AE00DFC0C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = NA5BE2D52P; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + ECF4104B2F03E6AE00DFC0C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = NA5BE2D52P; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + ECF4104D2F03E6AE00DFC0C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ClaudeCodeUsage/ClaudeCodeUsage.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsage; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + ECF4104E2F03E6AE00DFC0C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = ClaudeCodeUsage/ClaudeCodeUsage.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = YES; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsage; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + ECF410502F03E6AE00DFC0C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsageTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ClaudeCodeUsage.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ClaudeCodeUsage"; + }; + name = Debug; + }; + ECF410512F03E6AE00DFC0C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 26.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsageTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ClaudeCodeUsage.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ClaudeCodeUsage"; + }; + name = Release; + }; + ECF410532F03E6AE00DFC0C8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsageUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = ClaudeCodeUsage; + }; + name = Debug; + }; + ECF410542F03E6AE00DFC0C8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = NA5BE2D52P; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.unchartedworks.ClaudeCodeUsageUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = ClaudeCodeUsage; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + ECF410262F03E6AD00DFC0C8 /* Build configuration list for PBXProject "ClaudeCodeUsage" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ECF4104A2F03E6AE00DFC0C8 /* Debug */, + ECF4104B2F03E6AE00DFC0C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + ECF4104C2F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsage" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ECF4104D2F03E6AE00DFC0C8 /* Debug */, + ECF4104E2F03E6AE00DFC0C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + ECF4104F2F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsageTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ECF410502F03E6AE00DFC0C8 /* Debug */, + ECF410512F03E6AE00DFC0C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + ECF410522F03E6AE00DFC0C8 /* Build configuration list for PBXNativeTarget "ClaudeCodeUsageUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + ECF410532F03E6AE00DFC0C8 /* Debug */, + ECF410542F03E6AE00DFC0C8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + ECF4108B2F03EC6700DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ClaudeUsageCore; + }; + ECF4108E2F03EC7800DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageData" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ClaudeUsageData; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + ECF410562F03E6FD00DFC0C8 /* ClaudeUsageCore */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageCore; + }; + ECF410582F03E6FD00DFC0C8 /* ClaudeUsageData */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageData; + }; + ECF4105B2F03EA3E00DFC0C8 /* ClaudeUsageCore */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageCore; + }; + ECF4105E2F03EA5900DFC0C8 /* ClaudeUsageData */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageData; + }; + ECF4108C2F03EC6700DFC0C8 /* ClaudeUsageCore */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageCore; + }; + ECF4108F2F03EC7800DFC0C8 /* ClaudeUsageData */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageData; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = ECF410232F03E6AD00DFC0C8 /* Project object */; +} diff --git a/ClaudeCodeUsage.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ClaudeCodeUsage.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ClaudeCodeUsage.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Sources/ClaudeUsageUI/AppLifecycleManager.swift b/ClaudeCodeUsage/AppLifecycleManager.swift similarity index 100% rename from Sources/ClaudeUsageUI/AppLifecycleManager.swift rename to ClaudeCodeUsage/AppLifecycleManager.swift diff --git a/ClaudeCodeUsage/Assets.xcassets/AccentColor.colorset/Contents.json b/ClaudeCodeUsage/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/ClaudeCodeUsage/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ClaudeCodeUsage/Assets.xcassets/AppIcon.appiconset/Contents.json b/ClaudeCodeUsage/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3f00db4 --- /dev/null +++ b/ClaudeCodeUsage/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ClaudeCodeUsage/Assets.xcassets/Contents.json b/ClaudeCodeUsage/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ClaudeCodeUsage/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ClaudeCodeUsage/ClaudeCodeUsage.entitlements b/ClaudeCodeUsage/ClaudeCodeUsage.entitlements new file mode 100644 index 0000000..4c3fbcf --- /dev/null +++ b/ClaudeCodeUsage/ClaudeCodeUsage.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/ClaudeCodeUsage/ClaudeCodeUsageApp.swift b/ClaudeCodeUsage/ClaudeCodeUsageApp.swift new file mode 100644 index 0000000..bb85529 --- /dev/null +++ b/ClaudeCodeUsage/ClaudeCodeUsageApp.swift @@ -0,0 +1,93 @@ +// +// ClaudeCodeUsageApp.swift +// ClaudeCodeUsage +// +// Created by Liang on 30-12-2025. +// + +// +// ClaudeCodeUsageApp.swift +// App entry point +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - App Entry Point + +@main +struct ClaudeCodeUsageApp: App { + @State private var store = UsageStore() + @State private var lifecycleManager = AppLifecycleManager() + @State private var settingsService = AppSettingsService() + + var body: some Scene { + mainWindow + menuBarScene + } + + private var mainWindow: some Scene { + Window(AppMetadata.name, id: "main") { + MainView(settingsService: settingsService) + .environment(store) + } + .defaultLaunchBehavior(.suppressed) + .windowStyle(.automatic) + .windowToolbarStyle(.unified) + .defaultSize(width: 840, height: 600) + .commands { AppCommands(settingsService: settingsService) } + } + + private var menuBarScene: some Scene { + MenuBarScene(store: store, settingsService: settingsService, lifecycleManager: lifecycleManager) + } +} + +// MARK: - App Commands + +struct AppCommands: Commands { + let settingsService: AppSettingsService + + var body: some Commands { + aboutCommand + settingsCommand + viewMenu + } + + private var aboutCommand: some Commands { + CommandGroup(replacing: .appInfo) { + Button("About \(AppMetadata.name)") { + settingsService.showAboutPanel() + } + } + } + + private var settingsCommand: some Commands { + CommandGroup(after: .appSettings) { + OpenAtLoginToggle(settingsService: settingsService) + } + } + + private var viewMenu: some Commands { + CommandMenu("View") { + refreshButton + Divider() + showWindowButton + } + } + + private var refreshButton: some View { + Button("Refresh") { + NotificationCenter.default.post(name: .refreshData, object: nil) + } + .keyboardShortcut("R", modifiers: .command) + } + + private var showWindowButton: some View { + Button("Show Main Window") { + WindowActions.showMainWindow() + } + .keyboardShortcut("1", modifiers: .command) + } +} + diff --git a/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift b/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift new file mode 100644 index 0000000..ec1bf28 --- /dev/null +++ b/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift @@ -0,0 +1,105 @@ +// +// AnalyticsRows.swift +// Shared row components for analytics cards +// + +import SwiftUI +import ClaudeUsageCore + +struct TokenRow: View { + let label: String + let percentage: Double + let icon: String + let color: Color + + var body: some View { + HStack { + Label(label, systemImage: icon) + .foregroundColor(color) + Spacer() + Text(percentage.asPercentage) + .font(.system(.body, design: .monospaced)) + } + } +} + +struct PredictionRow: View { + let label: String + let value: String + let icon: String + let detail: String? + + var body: some View { + HStack { + Label(label, systemImage: icon) + Spacer() + valueSection + } + } + + private var valueSection: some View { + VStack(alignment: .trailing) { + valueText + detailText + } + } + + private var valueText: some View { + Text(value) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + } + + @ViewBuilder + private var detailText: some View { + if let detail { + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +struct TrendRow: View { + let trend: UsageTrend + + var body: some View { + HStack { + Label("7-Day Trend", systemImage: trend.icon) + .foregroundColor(trend.color) + Spacer() + Text(trend.formattedPercentage) + .font(.system(.body, design: .monospaced)) + .foregroundColor(trend.color) + } + } +} + +struct InfoRow: View { + let label: String + let value: String + let detail: String + + var body: some View { + HStack { + labelView + Spacer() + valueSection + } + } + + private var labelView: some View { + Text(label) + .foregroundColor(.secondary) + } + + private var valueSection: some View { + VStack(alignment: .trailing) { + Text(value) + .font(.subheadline) + Text(detail) + .font(.caption) + .foregroundColor(.secondary) + } + } +} diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift b/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift rename to ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift rename to ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift b/ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift rename to ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift b/ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift rename to ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/MainView.swift b/ClaudeCodeUsage/MainWindow/MainView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/MainView.swift rename to ClaudeCodeUsage/MainWindow/MainView.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift b/ClaudeCodeUsage/MainWindow/Models/ModelsView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift rename to ClaudeCodeUsage/MainWindow/Models/ModelsView.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift b/ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift rename to ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift diff --git a/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift b/ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift rename to ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift b/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift rename to ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift b/ClaudeCodeUsage/MenuBar/Components/GraphView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift rename to ClaudeCodeUsage/MenuBar/Components/GraphView.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift b/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift rename to ClaudeCodeUsage/MenuBar/Components/MetricRow.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift b/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift rename to ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift b/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift rename to ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift b/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift rename to ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift b/ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift rename to ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift b/ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift rename to ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift b/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift rename to ClaudeCodeUsage/MenuBar/MenuBarContentView.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift b/ClaudeCodeUsage/MenuBar/MenuBarScene.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift rename to ClaudeCodeUsage/MenuBar/MenuBarScene.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift rename to ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift rename to ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift rename to ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift b/ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift rename to ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift diff --git a/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift b/ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift similarity index 100% rename from Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift rename to ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift diff --git a/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift b/ClaudeCodeUsage/Settings/AppSettingsService.swift similarity index 100% rename from Sources/ClaudeUsageUI/Settings/AppSettingsService.swift rename to ClaudeCodeUsage/Settings/AppSettingsService.swift diff --git a/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift b/ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift similarity index 100% rename from Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift rename to ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift diff --git a/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift similarity index 100% rename from Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift rename to ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift diff --git a/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift similarity index 100% rename from Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift rename to ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift diff --git a/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift similarity index 100% rename from Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift rename to ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift diff --git a/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift b/ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift rename to ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift diff --git a/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift b/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift new file mode 100644 index 0000000..ad426d2 --- /dev/null +++ b/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift @@ -0,0 +1,24 @@ +// +// SystemClock.swift +// Production clock implementation using system time +// + +import Foundation + +// MARK: - System Clock + +/// Real clock implementation using system time +/// Explicitly nonisolated since it's stateless and used in default parameters +nonisolated struct SystemClock: ClockProtocol, Sendable { + var now: Date { + Date() + } + + func sleep(for duration: Duration) async throws { + try await Task.sleep(for: duration) + } + + func sleep(for seconds: TimeInterval) async throws { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } +} diff --git a/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift b/ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift rename to ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift diff --git a/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift b/ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift similarity index 66% rename from Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift rename to ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift index 7ea1753..17c3d50 100644 --- a/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift +++ b/ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift @@ -5,6 +5,16 @@ import Foundation +// MARK: - Home Directory Helper + +/// Returns the real user home directory, even in sandboxed apps. +/// In sandboxed apps, NSHomeDirectory() returns the container path, +/// but we need the actual user home to access ~/.claude +private func realHomeDirectory() -> String { + guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } + return String(cString: pw.pointee.pw_dir) +} + // MARK: - Configuration public struct AppConfiguration: Sendable { @@ -14,7 +24,7 @@ public struct AppConfiguration: Sendable { let dailyCostThreshold: Double static let `default` = AppConfiguration( - basePath: NSHomeDirectory() + "/.claude", + basePath: realHomeDirectory() + "/.claude", refreshInterval: 30.0, sessionDurationHours: 5.0, dailyCostThreshold: 10.0 diff --git a/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift b/ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift rename to ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift diff --git a/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift b/ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift rename to ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift diff --git a/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift b/ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift rename to ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift diff --git a/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift b/ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift similarity index 94% rename from Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift rename to ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift index 134019d..075130c 100644 --- a/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift +++ b/ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift @@ -7,6 +7,13 @@ import Foundation import AppKit import ClaudeUsageData +// MARK: - Home Directory Helper + +private func realHomeDirectory() -> String { + guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } + return String(cString: pw.pointee.pw_dir) +} + // MARK: - Timing Constants private enum Timing { @@ -40,7 +47,7 @@ final class RefreshCoordinator { init( clock: any ClockProtocol = SystemClock(), refreshInterval: TimeInterval, - basePath: String = NSHomeDirectory() + "/.claude" + basePath: String = realHomeDirectory() + "/.claude" ) { self.clock = clock self.lastRefreshTime = clock.now diff --git a/Sources/ClaudeUsageUI/Stores/UsageStore.swift b/ClaudeCodeUsage/Stores/UsageStore.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/UsageStore.swift rename to ClaudeCodeUsage/Stores/UsageStore.swift diff --git a/ClaudeCodeUsageTests/ClaudeCodeUsageTests.swift b/ClaudeCodeUsageTests/ClaudeCodeUsageTests.swift new file mode 100644 index 0000000..43293c9 --- /dev/null +++ b/ClaudeCodeUsageTests/ClaudeCodeUsageTests.swift @@ -0,0 +1,17 @@ +// +// ClaudeCodeUsageTests.swift +// ClaudeCodeUsageTests +// +// Created by Liang on 30-12-2025. +// + +import Testing +@testable import ClaudeCodeUsage + +struct ClaudeCodeUsageTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + } + +} diff --git a/ClaudeCodeUsageUITests/ClaudeCodeUsageUITests.swift b/ClaudeCodeUsageUITests/ClaudeCodeUsageUITests.swift new file mode 100644 index 0000000..6211a65 --- /dev/null +++ b/ClaudeCodeUsageUITests/ClaudeCodeUsageUITests.swift @@ -0,0 +1,41 @@ +// +// ClaudeCodeUsageUITests.swift +// ClaudeCodeUsageUITests +// +// Created by Liang on 30-12-2025. +// + +import XCTest + +final class ClaudeCodeUsageUITests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + + // In UI tests it is usually best to stop immediately when a failure occurs. + continueAfterFailure = false + + // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + @MainActor + func testExample() throws { + // UI tests must launch the application that they test. + let app = XCUIApplication() + app.launch() + + // Use XCTAssert and related functions to verify your tests produce the correct results. + } + + @MainActor + func testLaunchPerformance() throws { + // This measures how long it takes to launch your application. + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/ClaudeCodeUsageUITests/ClaudeCodeUsageUITestsLaunchTests.swift b/ClaudeCodeUsageUITests/ClaudeCodeUsageUITestsLaunchTests.swift new file mode 100644 index 0000000..66232f9 --- /dev/null +++ b/ClaudeCodeUsageUITests/ClaudeCodeUsageUITestsLaunchTests.swift @@ -0,0 +1,33 @@ +// +// ClaudeCodeUsageUITestsLaunchTests.swift +// ClaudeCodeUsageUITests +// +// Created by Liang on 30-12-2025. +// + +import XCTest + +final class ClaudeCodeUsageUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + // Insert steps here to perform after app launch but before taking a screenshot, + // such as logging into a test account or navigating somewhere in the app + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Package.swift b/Package.swift index 5a683df..f3567fe 100644 --- a/Package.swift +++ b/Package.swift @@ -8,80 +8,46 @@ let package = Package( .macOS(.v15), ], products: [ - // Domain layer - pure types, protocols, analytics + // Re-export products from standalone packages .library( name: "ClaudeUsageCore", - targets: ["ClaudeUsageCore"]), - // Data layer - repository, parsing, monitoring + targets: ["ClaudeUsageCoreWrapper"]), .library( name: "ClaudeUsageData", - targets: ["ClaudeUsageData"]), - // UI layer - SwiftUI views and stores + targets: ["ClaudeUsageDataWrapper"]), .library( name: "ClaudeUsageUI", - targets: ["ClaudeUsageUI"]), - // macOS menu bar app - .executable( - name: "ClaudeCodeUsage", - targets: ["ClaudeUsage"]) + targets: ["ClaudeUsageUIWrapper"]), + ], + dependencies: [ + .package(path: "Packages/ClaudeUsageCore"), + .package(path: "Packages/ClaudeUsageData"), + .package(path: "Packages/ClaudeUsageUI"), ], - dependencies: [], targets: [ - // MARK: - Domain Layer (no dependencies) - - .target( - name: "ClaudeUsageCore", - dependencies: [], - path: "Sources/ClaudeUsageCore"), - - // MARK: - Data Layer (depends on Core) - - .target( - name: "ClaudeUsageData", - dependencies: ["ClaudeUsageCore"], - path: "Sources/ClaudeUsageData"), - - // MARK: - UI Layer (SwiftUI views, stores) - + // Thin wrappers that re-export standalone packages .target( - name: "ClaudeUsageUI", + name: "ClaudeUsageCoreWrapper", dependencies: [ - "ClaudeUsageCore", - "ClaudeUsageData" + .product(name: "ClaudeUsageCore", package: "ClaudeUsageCore"), ], - path: "Sources/ClaudeUsageUI"), - - // MARK: - App Entry Point + path: "Sources/Wrappers/Core"), - .executableTarget( - name: "ClaudeUsage", + .target( + name: "ClaudeUsageDataWrapper", dependencies: [ - "ClaudeUsageUI" + .product(name: "ClaudeUsageData", package: "ClaudeUsageData"), ], - path: "Sources/ClaudeUsage"), - - // MARK: - Tests + path: "Sources/Wrappers/Data"), - .testTarget( - name: "ClaudeUsageCoreTests", - dependencies: ["ClaudeUsageCore"], - path: "Tests/ClaudeUsageCoreTests"), - - .testTarget( - name: "ClaudeUsageDataTests", - dependencies: ["ClaudeUsageData"], - path: "Tests/ClaudeUsageDataTests"), - - .testTarget( - name: "ClaudeUsageTests", + .target( + name: "ClaudeUsageUIWrapper", dependencies: [ - "ClaudeUsageUI", - "ClaudeUsageData" + .product(name: "ClaudeUsageUI", package: "ClaudeUsageUI"), ], - path: "Tests/ClaudeUsageTests", - swiftSettings: [ - .unsafeFlags(["-enable-testing"]), - .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug)) - ]), + path: "Sources/Wrappers/UI"), ] ) + +// Note: To run the app, use: +// swift run --package-path Packages/ClaudeUsageUI ClaudeCodeUsage diff --git a/Packages/ClaudeUsageCore/Package.swift b/Packages/ClaudeUsageCore/Package.swift new file mode 100644 index 0000000..48264dd --- /dev/null +++ b/Packages/ClaudeUsageCore/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "ClaudeUsageCore", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "ClaudeUsageCore", + targets: ["ClaudeUsageCore"]), + ], + targets: [ + .target( + name: "ClaudeUsageCore", + dependencies: []), + + .testTarget( + name: "ClaudeUsageCoreTests", + dependencies: ["ClaudeUsageCore"]), + ] +) diff --git a/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift similarity index 100% rename from Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift diff --git a/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift similarity index 100% rename from Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift diff --git a/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift similarity index 100% rename from Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift diff --git a/Sources/ClaudeUsageCore/ClaudeUsageCore.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/ClaudeUsageCore.swift similarity index 100% rename from Sources/ClaudeUsageCore/ClaudeUsageCore.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/ClaudeUsageCore.swift diff --git a/Sources/ClaudeUsageCore/Models/BurnRate.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/BurnRate.swift similarity index 100% rename from Sources/ClaudeUsageCore/Models/BurnRate.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/BurnRate.swift diff --git a/Sources/ClaudeUsageCore/Models/SessionBlock.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/SessionBlock.swift similarity index 100% rename from Sources/ClaudeUsageCore/Models/SessionBlock.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/SessionBlock.swift diff --git a/Sources/ClaudeUsageCore/Models/TokenCounts.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/TokenCounts.swift similarity index 100% rename from Sources/ClaudeUsageCore/Models/TokenCounts.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/TokenCounts.swift diff --git a/Sources/ClaudeUsageCore/Models/UsageEntry.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageEntry.swift similarity index 100% rename from Sources/ClaudeUsageCore/Models/UsageEntry.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageEntry.swift diff --git a/Sources/ClaudeUsageCore/Models/UsageStats.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageStats.swift similarity index 100% rename from Sources/ClaudeUsageCore/Models/UsageStats.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageStats.swift diff --git a/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift similarity index 100% rename from Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift diff --git a/Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift b/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift similarity index 100% rename from Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift rename to Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift diff --git a/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift b/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift similarity index 100% rename from Tests/ClaudeUsageCoreTests/TokenCountsTests.swift rename to Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift diff --git a/Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift b/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift similarity index 100% rename from Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift rename to Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift diff --git a/Packages/ClaudeUsageData/Package.swift b/Packages/ClaudeUsageData/Package.swift new file mode 100644 index 0000000..f8b26a6 --- /dev/null +++ b/Packages/ClaudeUsageData/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "ClaudeUsageData", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "ClaudeUsageData", + targets: ["ClaudeUsageData"]), + ], + dependencies: [ + .package(path: "../ClaudeUsageCore"), + ], + targets: [ + .target( + name: "ClaudeUsageData", + dependencies: ["ClaudeUsageCore"]), + + .testTarget( + name: "ClaudeUsageDataTests", + dependencies: ["ClaudeUsageData"]), + ] +) diff --git a/Sources/ClaudeUsageData/ClaudeUsageData.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/ClaudeUsageData.swift similarity index 100% rename from Sources/ClaudeUsageData/ClaudeUsageData.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/ClaudeUsageData.swift diff --git a/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift similarity index 100% rename from Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift diff --git a/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift similarity index 100% rename from Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift diff --git a/Sources/ClaudeUsageData/Parsing/JSONLParser.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Parsing/JSONLParser.swift similarity index 100% rename from Sources/ClaudeUsageData/Parsing/JSONLParser.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/Parsing/JSONLParser.swift diff --git a/Sources/ClaudeUsageData/Repository/FileDiscovery.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/FileDiscovery.swift similarity index 100% rename from Sources/ClaudeUsageData/Repository/FileDiscovery.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/FileDiscovery.swift diff --git a/Sources/ClaudeUsageData/Repository/UsageRepository.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/UsageRepository.swift similarity index 100% rename from Sources/ClaudeUsageData/Repository/UsageRepository.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/UsageRepository.swift diff --git a/Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift b/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift similarity index 100% rename from Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift rename to Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift diff --git a/Tests/ClaudeUsageDataTests/JSONLParserTests.swift b/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/JSONLParserTests.swift similarity index 100% rename from Tests/ClaudeUsageDataTests/JSONLParserTests.swift rename to Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/JSONLParserTests.swift diff --git a/Tests/ClaudeUsageDataTests/SessionMonitorTests.swift b/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/SessionMonitorTests.swift similarity index 100% rename from Tests/ClaudeUsageDataTests/SessionMonitorTests.swift rename to Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/SessionMonitorTests.swift diff --git a/Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift b/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift similarity index 100% rename from Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift rename to Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift diff --git a/Packages/ClaudeUsageUI/Package.swift b/Packages/ClaudeUsageUI/Package.swift new file mode 100644 index 0000000..c648d9b --- /dev/null +++ b/Packages/ClaudeUsageUI/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "ClaudeUsageUI", + platforms: [ + .macOS(.v15), + ], + products: [ + .library( + name: "ClaudeUsageUI", + targets: ["ClaudeUsageUI"]), + .executable( + name: "ClaudeCodeUsage", + targets: ["ClaudeCodeUsage"]), + ], + dependencies: [ + .package(path: "../ClaudeUsageCore"), + .package(path: "../ClaudeUsageData"), + ], + targets: [ + .target( + name: "ClaudeUsageUI", + dependencies: [ + "ClaudeUsageCore", + "ClaudeUsageData", + ]), + + .executableTarget( + name: "ClaudeCodeUsage", + dependencies: ["ClaudeUsageUI"]), + + .testTarget( + name: "ClaudeUsageUITests", + dependencies: [ + "ClaudeUsageUI", + "ClaudeUsageData", + ], + swiftSettings: [ + .unsafeFlags(["-enable-testing"]), + .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug)), + ]), + ] +) diff --git a/Sources/ClaudeUsage/App/ClaudeCodeUsageApp.swift b/Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift similarity index 100% rename from Sources/ClaudeUsage/App/ClaudeCodeUsageApp.swift rename to Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift new file mode 100644 index 0000000..2e5591d --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift @@ -0,0 +1,53 @@ +// +// AppLifecycleManager.swift +// Centralized app lifecycle and notification management +// + +import SwiftUI +import Observation +import Combine + +@Observable +@MainActor +public final class AppLifecycleManager { + private var cancellables = Set() + private weak var store: UsageStore? + + public init() { + setupNotificationHandlers() + } + + public func configure(with store: UsageStore) { + self.store = store + } + + private func setupNotificationHandlers() { + observe(NSApplication.didBecomeActiveNotification) { [weak self] in self?.handleAppBecameActive() } + observe(NSApplication.didResignActiveNotification) { [weak self] in self?.handleAppResignActive() } + observe(NSWindow.didBecomeKeyNotification) { [weak self] in self?.handleWindowFocus() } + observe(NSWindow.willCloseNotification) { [weak self] in self?.handleWindowWillClose() } + } + + private func observe(_ notification: NSNotification.Name, handler: @escaping () -> Void) { + NotificationCenter.default.publisher(for: notification) + .receive(on: DispatchQueue.main) + .sink { _ in handler() } + .store(in: &cancellables) + } + + private func handleAppBecameActive() { + store?.handleAppBecameActive() + } + + private func handleAppResignActive() { + store?.handleAppResignActive() + } + + private func handleWindowFocus() { + store?.handleWindowFocus() + } + + private func handleWindowWillClose() { + store?.stopRefreshTimer() + } +} \ No newline at end of file diff --git a/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift similarity index 100% rename from Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift rename to Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift new file mode 100644 index 0000000..2493c07 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift @@ -0,0 +1,107 @@ +// +// AnalyticsView.swift +// Analytics and insights view +// + +import SwiftUI +import ClaudeUsageCore + +struct AnalyticsView: View { + @Environment(UsageStore.self) private var store + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + AnalyticsHeader() + AnalyticsContent(state: ContentState.from(store: store)) + } + .padding() + } + .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Content State + +@MainActor +private enum ContentState { + case loading + case loaded(UsageStats) + case error + + static func from(store: UsageStore) -> ContentState { + if store.isLoading { return .loading } + guard let stats = store.stats else { return .error } + return .loaded(stats) + } +} + +// MARK: - Content Router + +private struct AnalyticsContent: View { + let state: ContentState + + var body: some View { + switch state { + case .loading: + LoadingView(message: "Analyzing data...") + case .loaded(let stats): + AnalyticsCards(stats: stats) + case .error: + EmptyStateView( + icon: "chart.bar.xaxis", + title: "No Analytics Available", + message: "Analytics will appear once you have usage data." + ) + } + } +} + +private struct AnalyticsCards: View { + let stats: UsageStats + + var body: some View { + VStack(spacing: 16) { + YearlyCostHeatmapCard(stats: stats) + TokenDistributionCard(stats: stats) + PredictionsCard(stats: stats) + EfficiencyCard(stats: stats) + TrendsCard(stats: stats) + } + } +} + +// MARK: - Header + +private struct AnalyticsHeader: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + titleView + subtitleView + } + } + + private var titleView: some View { + Text("Analytics") + .font(.largeTitle) + .fontWeight(.bold) + } + + private var subtitleView: some View { + Text("Insights and predictions based on your usage") + .font(.subheadline) + .foregroundColor(.secondary) + } +} + +// MARK: - Loading View + +private struct LoadingView: View { + let message: String + + var body: some View { + ProgressView(message) + .frame(maxWidth: .infinity) + .padding(.top, 50) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift new file mode 100644 index 0000000..e059aa5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift @@ -0,0 +1,33 @@ +// +// AnalyticsCard.swift +// Reusable card container for analytics views +// + +import SwiftUI + +struct AnalyticsCard: View { + let title: String + let icon: String + let color: Color + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + headerView + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + } + + private var headerView: some View { + HStack { + Image(systemName: icon) + .foregroundColor(color) + Text(title) + .font(.headline) + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift new file mode 100644 index 0000000..b936e4f --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift @@ -0,0 +1,58 @@ +// +// PredictionsCard.swift +// Cost predictions based on usage data +// + +import SwiftUI +import ClaudeUsageCore + +struct PredictionsCard: View { + let stats: UsageStats + + private var metrics: PredictionMetrics { PredictionMetrics.from(stats: stats) } + + var body: some View { + AnalyticsCard(title: "Predictions", icon: "calendar", color: .green) { + VStack(alignment: .leading, spacing: 12) { + monthlyCostRow + dailyCostRow + } + } + } + + private var monthlyCostRow: some View { + PredictionRow( + label: "Predicted Monthly Cost", + value: metrics.monthlyCost.asCurrency, + icon: "calendar", + detail: "Based on \(metrics.daysElapsed) days of data" + ) + } + + @ViewBuilder + private var dailyCostRow: some View { + if let daily = metrics.averageDailyCost { + PredictionRow( + label: "Average Daily Cost", + value: daily.asCurrency, + icon: "chart.line.uptrend.xyaxis", + detail: nil + ) + } + } +} + +// MARK: - Pure Transformation + +private struct PredictionMetrics { + let daysElapsed: Int + let monthlyCost: Double + let averageDailyCost: Double? + + static func from(stats: UsageStats) -> PredictionMetrics { + let days = max(1, stats.byDate.count) + let monthly = UsageAnalytics.predictMonthlyCost(from: stats, daysElapsed: days) + let daily = stats.byDate.isEmpty ? nil : stats.totalCost / Double(stats.byDate.count) + return PredictionMetrics(daysElapsed: days, monthlyCost: monthly, averageDailyCost: daily) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift new file mode 100644 index 0000000..ad965e5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift @@ -0,0 +1,46 @@ +// +// TokenDistributionCard.swift +// Token distribution visualization +// + +import SwiftUI +import ClaudeUsageCore + +struct TokenDistributionCard: View { + let stats: UsageStats + + var body: some View { + AnalyticsCard(title: "Token Distribution", icon: "chart.pie", color: .blue) { + TokenDistributionRows(breakdown: UsageAnalytics.tokenBreakdown(from: stats)) + } + } +} + +private struct TokenDistributionRows: View { + let breakdown: (inputPercentage: Double, outputPercentage: Double, cacheWritePercentage: Double, cacheReadPercentage: Double) + + var body: some View { + VStack(spacing: 12) { + inputRow + outputRow + cacheWriteRow + cacheReadRow + } + } + + private var inputRow: some View { + TokenRow(label: "Input", percentage: breakdown.inputPercentage, icon: "arrow.right.circle", color: .blue) + } + + private var outputRow: some View { + TokenRow(label: "Output", percentage: breakdown.outputPercentage, icon: "arrow.left.circle", color: .green) + } + + private var cacheWriteRow: some View { + TokenRow(label: "Cache Write", percentage: breakdown.cacheWritePercentage, icon: "square.and.pencil", color: .orange) + } + + private var cacheReadRow: some View { + TokenRow(label: "Cache Read", percentage: breakdown.cacheReadPercentage, icon: "doc.text.magnifyingglass", color: .purple) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift new file mode 100644 index 0000000..0765f36 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift @@ -0,0 +1,115 @@ +// +// UsageTrendsCard.swift +// Usage trends and efficiency analytics +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Trends Card + +struct TrendsCard: View { + let stats: UsageStats + + var body: some View { + AnalyticsCard(title: "Usage Trends", icon: "chart.line.uptrend.xyaxis", color: .orange) { + VStack(alignment: .leading, spacing: 12) { + weeklyTrendSection + peakDaySection + } + } + } + + @ViewBuilder + private var weeklyTrendSection: some View { + if let trend = TrendCalculator.weeklyTrend(from: stats) { + TrendRow(trend: trend) + } + } + + @ViewBuilder + private var peakDaySection: some View { + if let peak = TrendCalculator.peakDay(from: stats) { + InfoRow( + label: "Peak Usage Day", + value: DateFormatting.formatMedium(peak.date), + detail: peak.totalCost.asCurrency + ) + } + } +} + +// MARK: - Efficiency Card + +struct EfficiencyCard: View { + let stats: UsageStats + + var body: some View { + AnalyticsCard(title: "Efficiency", icon: "memorychip", color: .purple) { + VStack(alignment: .leading, spacing: 12) { + Text(UsageAnalytics.cacheSavings(from: stats).description) + .font(.body) + .foregroundColor(.primary) + } + } + } +} + +// MARK: - Pure Transformations + +private enum TrendCalculator { + static func weeklyTrend(from stats: UsageStats) -> UsageTrend? { + guard stats.byDate.count >= 2 else { return nil } + + let recent = stats.byDate.suffix(7) + let previous = stats.byDate.dropLast(7).suffix(7) + + guard !recent.isEmpty, !previous.isEmpty else { return nil } + + let recentAvg = recent.map(\.totalCost).reduce(0, +) / Double(recent.count) + let previousAvg = previous.map(\.totalCost).reduce(0, +) / Double(previous.count) + + let change = ((recentAvg - previousAvg) / previousAvg) * 100 + return UsageTrend(direction: change > 0 ? .up : .down, percentage: abs(change)) + } + + static func peakDay(from stats: UsageStats) -> DailyUsage? { + stats.byDate.max { $0.totalCost < $1.totalCost } + } +} + +private enum DateFormatting { + private static let inputFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() + + static func formatMedium(_ dateString: String) -> String { + guard let date = inputFormatter.date(from: dateString) else { return dateString } + let output = DateFormatter() + output.dateStyle = .medium + return output.string(from: date) + } +} + +// MARK: - Supporting Types + +struct UsageTrend { + enum Direction { case up, down } + + let direction: Direction + let percentage: Double + + var icon: String { + direction == .up ? "arrow.up.right" : "arrow.down.right" + } + + var color: Color { + direction == .up ? .red : .green + } + + var formattedPercentage: String { + "\(direction == .up ? "+" : "-")\(percentage.asPercentage)" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift new file mode 100644 index 0000000..727c172 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift @@ -0,0 +1,79 @@ +// +// YearlyCostHeatmapCard.swift +// Yearly cost heatmap visualization +// + +import SwiftUI +import ClaudeUsageCore + +struct YearlyCostHeatmapCard: View { + let stats: UsageStats + @State private var selectedYear: Int = Calendar.current.component(.year, from: Date()) + + private var availableYears: [Int] { YearExtractor.years(from: stats.byDate) } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HeatmapHeader(years: availableYears, selectedYear: $selectedYear) + YearlyCostHeatmap(stats: stats, year: selectedYear) + } + .onAppear { + if let mostRecent = availableYears.first { + selectedYear = mostRecent + } + } + } +} + +private struct HeatmapHeader: View { + let years: [Int] + @Binding var selectedYear: Int + + var body: some View { + HStack { + Spacer() + if years.count > 1 { + YearSelector(years: years, selectedYear: $selectedYear) + } + } + } +} + +private struct YearSelector: View { + let years: [Int] + @Binding var selectedYear: Int + + var body: some View { + Menu { + ForEach(years, id: \.self) { year in + Button(String(year)) { selectedYear = year } + } + } label: { + menuLabel + } + .buttonStyle(.plain) + } + + private var menuLabel: some View { + HStack(spacing: 4) { + Text(String(selectedYear)) + .font(.subheadline) + .fontWeight(.medium) + Image(systemName: "chevron.down") + .font(.caption) + } + .foregroundColor(.primary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + } +} + +// MARK: - Pure Transformation + +private enum YearExtractor { + static func years(from dates: [DailyUsage]) -> [Int] { + Array(Set(dates.compactMap { Int($0.date.prefix(4)) })).sorted(by: >) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift new file mode 100644 index 0000000..6d92b2e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift @@ -0,0 +1,81 @@ +// +// HeatmapConfiguration+Accessibility.swift +// +// Accessibility settings and validation for heatmap configuration. +// + +import Foundation + +// MARK: - Accessibility Settings + +/// Accessibility configuration for the heatmap +public struct HeatmapAccessibility: Equatable, Sendable { + + /// Whether to provide accessibility labels + public let enableAccessibilityLabels: Bool + + /// Whether to provide accessibility values + public let enableAccessibilityValues: Bool + + /// Whether to group accessibility elements + public let groupAccessibilityElements: Bool + + /// Custom accessibility prefix for dates + public let dateAccessibilityPrefix: String + + /// Custom accessibility prefix for costs + public let costAccessibilityPrefix: String + + /// Default accessibility configuration + public static let `default` = HeatmapAccessibility( + enableAccessibilityLabels: true, + enableAccessibilityValues: true, + groupAccessibilityElements: true, + dateAccessibilityPrefix: "Usage on", + costAccessibilityPrefix: "Cost:" + ) + + /// Disabled accessibility for performance-critical scenarios + public static let disabled = HeatmapAccessibility( + enableAccessibilityLabels: false, + enableAccessibilityValues: false, + groupAccessibilityElements: false, + dateAccessibilityPrefix: "", + costAccessibilityPrefix: "" + ) +} + +// MARK: - Validation + +public extension HeatmapConfiguration { + + /// Validates the configuration and returns any issues + func validate() -> [String] { + ValidationRules.validate(self) + } + + /// Whether the configuration is valid + var isValid: Bool { + validate().isEmpty + } +} + +// MARK: - Validation Rules + +enum ValidationRules { + /// All validation rules as closures that return optional error message + static let rules: [@Sendable (HeatmapConfiguration) -> String?] = [ + { $0.squareSize <= 0 ? "Square size must be greater than 0" : nil }, + { $0.spacing < 0 ? "Spacing cannot be negative" : nil }, + { $0.cornerRadius < 0 ? "Corner radius cannot be negative" : nil }, + { $0.tooltipDelay < 0 ? "Tooltip delay cannot be negative" : nil }, + { $0.animationDuration < 0 ? "Animation duration cannot be negative" : nil }, + { $0.hoverScale <= 0 ? "Hover scale must be greater than 0" : nil }, + { $0.todayHighlightWidth < 0 ? "Today highlight width cannot be negative" : nil } + ] + + /// Validate configuration and return all errors + static func validate(_ config: HeatmapConfiguration) -> [String] { + rules.compactMap { $0(config) } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift new file mode 100644 index 0000000..365321c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift @@ -0,0 +1,172 @@ +// +// HeatmapConfiguration+ColorThemes.swift +// +// Color theme definitions for heatmap visualization. +// + +import SwiftUI + +// MARK: - Color Themes + +/// Predefined color themes for heatmap visualization +public enum HeatmapColorTheme: String, CaseIterable, Equatable, @unchecked Sendable { + case github = "github" + case ocean = "ocean" + case sunset = "sunset" + case forest = "forest" + case monochrome = "monochrome" + + /// Display name for the theme + public var displayName: String { + switch self { + case .github: return "GitHub" + case .ocean: return "Ocean" + case .sunset: return "Sunset" + case .forest: return "Forest" + case .monochrome: return "Monochrome" + } + } + + /// Colors for this theme (light mode - legacy) + public var colors: [Color] { + colors(for: .light) + } + + /// Colors for this theme based on color scheme + public func colors(for scheme: ColorScheme) -> [Color] { + switch self { + case .github: + return scheme == .dark ? githubDarkColors : githubLightColors + case .ocean: + return scheme == .dark ? oceanDarkColors : oceanLightColors + case .sunset: + return scheme == .dark ? sunsetDarkColors : sunsetLightColors + case .forest: + return scheme == .dark ? forestDarkColors : forestLightColors + case .monochrome: + return scheme == .dark ? monochromeDarkColors : monochromeLightColors + } + } + + /// Get color for specific intensity level (legacy - light mode) + public func color(for level: Int) -> Color { + color(for: level, scheme: .light) + } + + /// Get color for specific intensity level and color scheme + public func color(for level: Int, scheme: ColorScheme) -> Color { + let themeColors = colors(for: scheme) + let index = max(0, min(themeColors.count - 1, level)) + return themeColors[index] + } + + // MARK: - GitHub Theme Colors + + private var githubLightColors: [Color] { + [ + Color(red: 235/255, green: 237/255, blue: 240/255), // Level 0: #ebedf0 + Color(red: 155/255, green: 233/255, blue: 168/255), // Level 1: #9be9a8 + Color(red: 64/255, green: 196/255, blue: 99/255), // Level 2: #40c463 + Color(red: 48/255, green: 161/255, blue: 78/255), // Level 3: #30a14e + Color(red: 33/255, green: 110/255, blue: 57/255) // Level 4: #216e39 + ] + } + + private var githubDarkColors: [Color] { + [ + Color(red: 22/255, green: 27/255, blue: 34/255), // Level 0: #161b22 + Color(red: 14/255, green: 68/255, blue: 41/255), // Level 1: #0e4429 + Color(red: 0/255, green: 109/255, blue: 50/255), // Level 2: #006d32 + Color(red: 38/255, green: 166/255, blue: 65/255), // Level 3: #26a641 + Color(red: 57/255, green: 211/255, blue: 83/255) // Level 4: #39d353 + ] + } + + // MARK: - Ocean Theme Colors + + private var oceanLightColors: [Color] { + [ + Color.gray.opacity(0.3), + Color.blue.opacity(0.25), + Color.blue.opacity(0.45), + Color.blue.opacity(0.65), + Color.blue + ] + } + + private var oceanDarkColors: [Color] { + [ + Color(white: 0.15), + Color.blue.opacity(0.35), + Color.blue.opacity(0.55), + Color.blue.opacity(0.75), + Color(red: 0.3, green: 0.6, blue: 1.0) + ] + } + + // MARK: - Sunset Theme Colors + + private var sunsetLightColors: [Color] { + [ + Color.gray.opacity(0.3), + Color.yellow.opacity(0.4), + Color.orange.opacity(0.6), + Color.red.opacity(0.7), + Color.red + ] + } + + private var sunsetDarkColors: [Color] { + [ + Color(white: 0.15), + Color.yellow.opacity(0.5), + Color.orange.opacity(0.7), + Color.red.opacity(0.8), + Color(red: 1.0, green: 0.3, blue: 0.3) + ] + } + + // MARK: - Forest Theme Colors + + private var forestLightColors: [Color] { + [ + Color.gray.opacity(0.3), + Color.mint.opacity(0.3), + Color.green.opacity(0.5), + Color.green.opacity(0.7), + Color(red: 0, green: 0.5, blue: 0) + ] + } + + private var forestDarkColors: [Color] { + [ + Color(white: 0.15), + Color.mint.opacity(0.4), + Color.green.opacity(0.6), + Color.green.opacity(0.8), + Color(red: 0.2, green: 0.8, blue: 0.2) + ] + } + + // MARK: - Monochrome Theme Colors + + private var monochromeLightColors: [Color] { + [ + Color.gray.opacity(0.3), + Color.gray.opacity(0.5), + Color.gray.opacity(0.7), + Color.gray.opacity(0.85), + Color.gray + ] + } + + private var monochromeDarkColors: [Color] { + [ + Color(white: 0.15), + Color(white: 0.35), + Color(white: 0.5), + Color(white: 0.65), + Color(white: 0.8) + ] + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift new file mode 100644 index 0000000..c15ee07 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift @@ -0,0 +1,205 @@ +// +// HeatmapConfiguration.swift +// Configuration models for heatmap customization +// +// Provides type-safe configuration options for heatmap appearance, +// layout, and behavior with sensible defaults. +// +// Split into extensions for focused responsibilities: +// - +ColorThemes: Color theme definitions +// - +Accessibility: Accessibility settings and validation +// + +import SwiftUI +import Foundation + +// MARK: - Configuration Struct + +/// Configuration settings for heatmap appearance and behavior +public struct HeatmapConfiguration: Equatable, @unchecked Sendable { + + // MARK: - Layout Settings + + /// Size of each day square in points + public let squareSize: CGFloat + + /// Spacing between day squares in points + public let spacing: CGFloat + + /// Corner radius for day squares + public let cornerRadius: CGFloat + + /// Padding around the entire heatmap + public let padding: EdgeInsets + + // MARK: - Visual Settings + + /// Color scheme for the heatmap + public let colorScheme: HeatmapColorTheme + + /// Whether to show month labels + public let showMonthLabels: Bool + + /// Whether to show day-of-week labels + public let showDayLabels: Bool + + /// Whether to show the legend + public let showLegend: Bool + + /// Font for month labels + public let monthLabelFont: Font + + /// Font for day labels + public let dayLabelFont: Font + + /// Font for legend text + public let legendFont: Font + + // MARK: - Interaction Settings + + /// Whether hover tooltips are enabled + public let enableTooltips: Bool + + /// Tooltip delay in seconds + public let tooltipDelay: Double + + /// Whether to highlight today's date + public let highlightToday: Bool + + /// Color for today's highlight border + public let todayHighlightColor: Color + + /// Width of today's highlight border + public let todayHighlightWidth: CGFloat + + // MARK: - Animation Settings + + /// Duration for hover animations + public let animationDuration: Double + + /// Whether to animate color transitions + public let animateColorTransitions: Bool + + /// Whether to scale squares on hover + public let scaleOnHover: Bool + + /// Scale factor for hover effect + public let hoverScale: CGFloat + + // MARK: - Default Configuration + + /// Standard configuration matching GitHub's contribution graph + public static let `default` = HeatmapConfiguration( + squareSize: 12, + spacing: 2, + cornerRadius: 2, + padding: EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + colorScheme: .github, + showMonthLabels: true, + showDayLabels: true, + showLegend: true, + monthLabelFont: .system(size: 10, weight: .medium), + dayLabelFont: .system(size: 9, weight: .regular), + legendFont: .caption, + enableTooltips: true, + tooltipDelay: 0.5, + highlightToday: true, + todayHighlightColor: .blue, + todayHighlightWidth: 2, + animationDuration: 0.1, + animateColorTransitions: false, // Disabled for performance + scaleOnHover: false, // Disabled for performance + hoverScale: 1.1 + ) + + /// Compact configuration for smaller displays + public static let compact = HeatmapConfiguration( + squareSize: 10, + spacing: 1.5, + cornerRadius: 1.5, + padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12), + colorScheme: .github, + showMonthLabels: true, + showDayLabels: false, // Hide day labels in compact mode + showLegend: true, + monthLabelFont: .system(size: 9, weight: .medium), + dayLabelFont: .system(size: 8, weight: .regular), + legendFont: .system(size: 10), + enableTooltips: true, + tooltipDelay: 0.3, + highlightToday: true, + todayHighlightColor: .blue, + todayHighlightWidth: 1.5, + animationDuration: 0.08, + animateColorTransitions: false, + scaleOnHover: false, + hoverScale: 1.05 + ) + + /// Performance-optimized configuration + public static let performanceOptimized = HeatmapConfiguration( + squareSize: 12, + spacing: 2, + cornerRadius: 2, + padding: EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), + colorScheme: .github, + showMonthLabels: true, + showDayLabels: true, + showLegend: true, + monthLabelFont: .system(size: 10, weight: .medium), + dayLabelFont: .system(size: 9, weight: .regular), + legendFont: .caption, + enableTooltips: true, + tooltipDelay: 0.0, // No delay for immediate feedback + highlightToday: true, + todayHighlightColor: .blue, + todayHighlightWidth: 2, + animationDuration: 0.0, // No animations for maximum performance + animateColorTransitions: false, + scaleOnHover: false, + hoverScale: 1.0 + ) + + // MARK: - Computed Properties + + /// Total cell size including spacing + public var cellSize: CGFloat { + squareSize + spacing + } + + /// Array of day labels based on configuration + public var dayLabels: [String] { + showDayLabels ? DayLabelsConstants.withLabels : DayLabelsConstants.empty + } +} + +// MARK: - Layout Constants + +/// Constants for heatmap layout calculations +public struct HeatmapLayoutConstants { + + /// Number of days in a week + public static let daysPerWeek = 7 + + /// Number of weeks to display (approximately 52-53 weeks) + public static let weeksPerYear = 53 + + /// Number of days in a rolling year + public static let rollingYearDays = 365 + + /// Minimum tooltip offset from cursor + public static let tooltipOffset = CGPoint(x: 10, y: -30) + + /// Default animation curve + public static let defaultAnimationCurve = Animation.easeInOut + + /// Performance threshold for number of hover targets + public static let performanceThreshold = 400 +} + +// MARK: - Day Labels Constants + +enum DayLabelsConstants { + static let withLabels = ["", "Mon", "", "Wed", "", "Fri", ""] + static let empty = Array(repeating: "", count: 7) +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift new file mode 100644 index 0000000..c0314c5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift @@ -0,0 +1,98 @@ +// +// DaySquare.swift +// Day square components for heatmap grid +// + +import SwiftUI + +// MARK: - Day Square Container + +/// Container for day squares handling both filled and empty states +struct DaySquareContainer: View { + let day: HeatmapDay? + let configuration: HeatmapConfiguration + let isHovered: Bool + let accessibility: HeatmapAccessibility + + var body: some View { + Group { + if let day = day { + DaySquare( + day: day, + configuration: configuration, + isHovered: isHovered, + accessibility: accessibility + ) + } else { + // Empty placeholder for consistent grid layout + Rectangle() + .fill(Color.clear) + .frame(width: configuration.squareSize, height: configuration.squareSize) + } + } + } +} + +// MARK: - Day Square + +/// Individual day square component with optimized rendering +struct DaySquare: View { + let day: HeatmapDay + let configuration: HeatmapConfiguration + let isHovered: Bool + let accessibility: HeatmapAccessibility + + @Environment(\.colorScheme) private var colorScheme + + // MARK: - Computed Properties + + private var dayColor: Color { + day.color(for: colorScheme) + } + + private var borderStyle: BorderStyle { + BorderStyle.forDay(day, isHovered: isHovered, config: configuration) + } + + private var scaleEffect: CGFloat { + configuration.scaleOnHover && isHovered ? configuration.hoverScale : 1.0 + } + + private var hoverAnimation: Animation? { + configuration.animationDuration > 0 + ? .easeInOut(duration: configuration.animationDuration) + : nil + } + + // MARK: - Body + + var body: some View { + Rectangle() + .fill(dayColor) + .frame(width: configuration.squareSize, height: configuration.squareSize) + .cornerRadius(configuration.cornerRadius) + .overlay( + RoundedRectangle(cornerRadius: configuration.cornerRadius) + .stroke(borderStyle.color, lineWidth: borderStyle.width) + ) + .scaleEffect(scaleEffect) + .animation(hoverAnimation, value: isHovered) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } + + // MARK: - Accessibility + + private var accessibilityLabel: String { + accessibility.enableAccessibilityLabels + ? "\(accessibility.dateAccessibilityPrefix) \(day.dateString)" + : "" + } + + private var accessibilityValue: String { + accessibility.enableAccessibilityValues + ? "\(accessibility.costAccessibilityPrefix) \(day.costString)" + : "" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift new file mode 100644 index 0000000..bd82ce8 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift @@ -0,0 +1,298 @@ +// +// HeatmapGrid.swift +// Reusable grid component for heatmap visualization +// + +import SwiftUI + +// MARK: - Heatmap Grid + +/// High-performance grid component for rendering heatmap data +public struct HeatmapGrid: View { + + // MARK: - Properties + + /// Heatmap dataset to display + let dataset: HeatmapDataset + + /// Configuration for grid appearance and behavior + let configuration: HeatmapConfiguration + + /// Currently hovered day (optional) + let hoveredDay: HeatmapDay? + + /// Hover event handler + let onHover: (CGPoint) -> Void + + /// End hover event handler + let onEndHover: () -> Void + + /// Accessibility configuration + private let accessibility: HeatmapAccessibility + + // MARK: - Initialization + + /// Initialize heatmap grid + /// - Parameters: + /// - dataset: Data to display + /// - configuration: Grid configuration + /// - hoveredDay: Currently hovered day + /// - accessibility: Accessibility settings + /// - onHover: Hover event handler + /// - onEndHover: End hover handler + public init( + dataset: HeatmapDataset, + configuration: HeatmapConfiguration, + hoveredDay: HeatmapDay? = nil, + accessibility: HeatmapAccessibility = .default, + onHover: @escaping (CGPoint) -> Void, + onEndHover: @escaping () -> Void + ) { + self.dataset = dataset + self.configuration = configuration + self.hoveredDay = hoveredDay + self.accessibility = accessibility + self.onHover = onHover + self.onEndHover = onEndHover + } + + // MARK: - Public API (High Level) + + public var body: some View { + VStack(spacing: 8) { + mainGridLayout + } + } + + // MARK: - Orchestration (Coordination) + + @ViewBuilder + private var mainGridLayout: some View { + HStack(alignment: .top, spacing: 8) { + dayLabelsSidebar + scrollableGridWithMonthLabels + } + } + + @ViewBuilder + private var dayLabelsSidebar: some View { + if configuration.showDayLabels { + VStack(spacing: 0) { + monthLabelsSpacer + dayLabelsColumn + } + } + } + + @ViewBuilder + private var scrollableGridWithMonthLabels: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + VStack(spacing: 8) { + monthLabelsRowIfNeeded + gridWithHoverOverlay + } + } + .onAppear { + scrollToLastWeekWithData(proxy: proxy) + } + } + .accessibilityElement(children: accessibility.groupAccessibilityElements ? .contain : .ignore) + .accessibilityLabel("Heatmap grid showing daily usage over time") + } + + private func scrollToLastWeekWithData(proxy: ScrollViewProxy) { + guard let lastWeekWithData = dataset.weeks.last(where: { $0.totalCost > 0 }) else { return } + proxy.scrollTo(lastWeekWithData.id, anchor: .trailing) + } + + @ViewBuilder + private var gridWithHoverOverlay: some View { + ZStack { + gridContent + hoverOverlayIfEnabled + } + } + + // MARK: - Content Builders (Mid Level) + + @ViewBuilder + private var monthLabelsSpacer: some View { + if configuration.showMonthLabels { + Spacer().frame(height: 20) + } + } + + @ViewBuilder + private var monthLabelsRowIfNeeded: some View { + if configuration.showMonthLabels { + monthLabelsRow + } + } + + @ViewBuilder + private var monthLabelsRow: some View { + ZStack(alignment: .topLeading) { + monthLabelsBackground + monthLabelItems + } + } + + @ViewBuilder + private var monthLabelsBackground: some View { + Rectangle() + .fill(Color.clear) + .frame(width: totalGridWidth, height: 20) + } + + @ViewBuilder + private var monthLabelItems: some View { + ForEach(dataset.monthLabels) { month in + monthLabel(for: month) + } + } + + @ViewBuilder + private func monthLabel(for month: HeatmapMonth) -> some View { + Text(month.name) + .font(configuration.monthLabelFont) + .foregroundColor(.secondary) + .accessibilityLabel(accessibility.enableAccessibilityLabels ? month.fullName : "") + .offset(x: monthLabelOffset(for: month), y: 0) + } + + @ViewBuilder + private var dayLabelsColumn: some View { + VStack(spacing: configuration.spacing) { + ForEach(Array(configuration.dayLabels.enumerated()), id: \.offset) { _, dayLabel in + dayLabelView(dayLabel) + } + } + } + + @ViewBuilder + private func dayLabelView(_ label: String) -> some View { + Text(label) + .font(configuration.dayLabelFont) + .foregroundColor(.secondary) + .frame(width: 28, height: configuration.squareSize, alignment: .trailing) + .accessibilityHidden(!accessibility.enableAccessibilityLabels) + } + + @ViewBuilder + private var gridContent: some View { + HStack(spacing: configuration.spacing) { + ForEach(dataset.weeks) { week in + WeekColumn( + week: week, + configuration: configuration, + hoveredDay: hoveredDay, + accessibility: accessibility + ) + .id(week.id) + } + } + .padding(.horizontal, 4) + } + + @ViewBuilder + private var hoverOverlayIfEnabled: some View { + if configuration.enableTooltips { + hoverOverlay + } + } + + @ViewBuilder + private var hoverOverlay: some View { + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .gesture(dragGesture) + .onContinuousHover(perform: handleHoverPhase) + } + + // MARK: - Layout Calculations (Low Level) + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { value in + onHover(value.location) + } + } + + private func handleHoverPhase(_ phase: HoverPhase) { + switch phase { + case .active(let location): + onHover(location) + case .ended: + onEndHover() + } + } + + private func monthLabelOffset(for month: HeatmapMonth) -> CGFloat { + let weekStartIndex = CGFloat(month.weekSpan.lowerBound) + let squareOffset = weekStartIndex * configuration.squareSize + let spacingOffset = weekStartIndex * configuration.spacing + let horizontalPadding: CGFloat = 4 + return squareOffset + spacingOffset + horizontalPadding + } + + private var totalGridWidth: CGFloat { + let weekCount = CGFloat(dataset.weeks.count) + let totalSpacing = (weekCount - 1) * configuration.spacing + let totalSquares = weekCount * configuration.squareSize + let horizontalPadding: CGFloat = 8 + return totalSquares + totalSpacing + horizontalPadding + } +} + +// MARK: - Preview + +#if DEBUG +struct HeatmapGrid_Previews: PreviewProvider { + static var previews: some View { + let sampleData = generateSampleDataset() + + HeatmapGrid( + dataset: sampleData, + configuration: .default, + onHover: { _ in }, + onEndHover: { } + ) + .frame(height: 200) + .padding() + .background(Color(.controlBackgroundColor)) + } + + private static func generateSampleDataset() -> HeatmapDataset { + let weeks = (0..<52).map { weekIndex in + let days = (0..<7).map { dayIndex -> HeatmapDay? in + let cost = Double.random(in: 0...5) + let date = Calendar.current.date(byAdding: .day, value: weekIndex * 7 + dayIndex, to: Date())! + return HeatmapDay( + date: date, + cost: cost, + dayOfYear: weekIndex * 7 + dayIndex, + weekOfYear: weekIndex, + dayOfWeek: dayIndex, + maxCost: 5.0 + ) + } + return HeatmapWeek(weekNumber: weekIndex, days: days) + } + + let months = [ + HeatmapMonth(name: "Jan", weekSpan: 0..<4), + HeatmapMonth(name: "Feb", weekSpan: 4..<8), + HeatmapMonth(name: "Mar", weekSpan: 8..<13), + ] + + return HeatmapDataset( + weeks: weeks, + monthLabels: months, + maxCost: 5.0, + dateRange: Date()...Calendar.current.date(byAdding: .year, value: 1, to: Date())! + ) + } +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift new file mode 100644 index 0000000..bac9eef --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift @@ -0,0 +1,53 @@ +// +// HeatmapGridLayout.swift +// Utility for calculating grid layout dimensions +// + +import SwiftUI + +// MARK: - Grid Layout Calculations + +/// Utility for calculating grid layout dimensions +public struct HeatmapGridLayout { + let configuration: HeatmapConfiguration + let dataset: HeatmapDataset + + /// Total width of the grid content + public var contentWidth: CGFloat { + let weekCount = CGFloat(dataset.weeks.count) + let totalSpacing = (weekCount - 1) * configuration.spacing + let totalSquares = weekCount * configuration.squareSize + return totalSquares + totalSpacing + 8 // 8 for padding + } + + /// Total height of the grid content + public var contentHeight: CGFloat { + let dayCount: CGFloat = 7 + let totalSpacing = (dayCount - 1) * configuration.spacing + let totalSquares = dayCount * configuration.squareSize + return totalSquares + totalSpacing + } + + /// Size required for the entire heatmap including labels + public var totalSize: CGSize { + let width = contentWidth + (configuration.showDayLabels ? 30 : 0) + let height = contentHeight + (configuration.showMonthLabels ? 20 : 0) + return CGSize(width: width, height: height) + } + + /// Calculate position of a day square + /// - Parameters: + /// - weekIndex: Week index in the grid + /// - dayIndex: Day index within the week + /// - Returns: Position of the day square + public func dayPosition(weekIndex: Int, dayIndex: Int) -> CGPoint { + // Calculate x position: (week_index * square_size) + (week_index * spacing) + horizontal_padding + let squareOffset = CGFloat(weekIndex) * configuration.squareSize + let spacingOffset = CGFloat(weekIndex) * configuration.spacing + let x = squareOffset + spacingOffset + 4 // 4 for horizontal padding + + // Calculate y position: (day_index * square_size) + (day_index * spacing) + let y = CGFloat(dayIndex) * configuration.cellSize + return CGPoint(x: x, y: y) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift new file mode 100644 index 0000000..f737c1c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift @@ -0,0 +1,53 @@ +// +// HeatmapGridPerformance.swift +// Performance optimization utilities for heatmap grid +// + +import SwiftUI + +// MARK: - Performance Optimizations + +/// Performance-optimized grid rendering strategies +public enum HeatmapGridPerformance { + + /// Maximum number of days before performance optimizations kick in + static let performanceThreshold = 400 + + /// Check if dataset requires performance optimizations + /// - Parameter dataset: Dataset to check + /// - Returns: True if performance optimizations should be applied + public static func requiresOptimization(for dataset: HeatmapDataset) -> Bool { + let totalDays = dataset.weeks.reduce(0) { count, week in + count + week.days.compactMap { $0 }.count + } + return totalDays > performanceThreshold + } + + /// Get recommended configuration for large datasets + /// - Parameter baseConfig: Base configuration to optimize + /// - Returns: Performance-optimized configuration + public static func optimizedConfiguration(from baseConfig: HeatmapConfiguration) -> HeatmapConfiguration { + HeatmapConfiguration( + squareSize: baseConfig.squareSize, + spacing: baseConfig.spacing, + cornerRadius: baseConfig.cornerRadius, + padding: baseConfig.padding, + colorScheme: baseConfig.colorScheme, + showMonthLabels: baseConfig.showMonthLabels, + showDayLabels: baseConfig.showDayLabels, + showLegend: baseConfig.showLegend, + monthLabelFont: baseConfig.monthLabelFont, + dayLabelFont: baseConfig.dayLabelFont, + legendFont: baseConfig.legendFont, + enableTooltips: baseConfig.enableTooltips, + tooltipDelay: 0.0, // No delay for better performance + highlightToday: baseConfig.highlightToday, + todayHighlightColor: baseConfig.todayHighlightColor, + todayHighlightWidth: baseConfig.todayHighlightWidth, + animationDuration: 0.0, // Disable animations + animateColorTransitions: false, + scaleOnHover: false, // Disable scaling + hoverScale: 1.0 + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift new file mode 100644 index 0000000..cb357d5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift @@ -0,0 +1,29 @@ +// +// WeekColumn.swift +// Single week column component for heatmap grid +// + +import SwiftUI + +// MARK: - Week Column + +/// Single week column in the heatmap grid +struct WeekColumn: View { + let week: HeatmapWeek + let configuration: HeatmapConfiguration + let hoveredDay: HeatmapDay? + let accessibility: HeatmapAccessibility + + var body: some View { + VStack(spacing: configuration.spacing) { + ForEach(0..<7, id: \.self) { dayIndex in + DaySquareContainer( + day: week.days[safe: dayIndex] ?? nil, + configuration: configuration, + isHovered: hoveredDay?.id == week.days[safe: dayIndex]??.id, + accessibility: accessibility + ) + } + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift new file mode 100644 index 0000000..317dc4e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift @@ -0,0 +1,60 @@ +// +// HeatmapLegend+Factory.swift +// Convenience factory methods for common legend configurations +// + +import SwiftUI + +// MARK: - Convenience Extensions + +public extension HeatmapLegend { + + /// Create a minimal legend for compact displays + /// - Parameters: + /// - colorTheme: Color theme to display + /// - maxCost: Maximum cost for reference + /// - Returns: Configured minimal legend + static func minimal(colorTheme: HeatmapColorTheme, maxCost: Double) -> HeatmapLegend { + HeatmapLegend( + colorTheme: colorTheme, + maxCost: maxCost, + style: .compact, + showCostLabels: true, + showIntensityLabels: false + ) + } + + /// Create a detailed legend with all information + /// - Parameters: + /// - colorTheme: Color theme to display + /// - maxCost: Maximum cost for reference + /// - title: Optional custom title + /// - Returns: Configured detailed legend + static func detailed(colorTheme: HeatmapColorTheme, maxCost: Double, title: String? = nil) -> HeatmapLegend { + HeatmapLegend( + colorTheme: colorTheme, + maxCost: maxCost, + style: .horizontal, + showCostLabels: true, + showIntensityLabels: true, + customTitle: title + ) + } + + /// Create an accessibility-optimized legend + /// - Parameters: + /// - colorTheme: Color theme to display + /// - maxCost: Maximum cost for reference + /// - Returns: Accessibility-optimized legend + static func accessible(colorTheme: HeatmapColorTheme, maxCost: Double) -> HeatmapLegend { + HeatmapLegend( + colorTheme: colorTheme, + maxCost: maxCost, + style: .vertical, + font: .body, + showCostLabels: true, + showIntensityLabels: true, + accessibility: .default + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift new file mode 100644 index 0000000..08347b2 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift @@ -0,0 +1,297 @@ +// +// HeatmapLegend.swift +// Legend component for heatmap visualization +// + +import SwiftUI + +// MARK: - Heatmap Legend + +/// Reusable legend component for heatmap color scale +public struct HeatmapLegend: View { + + // MARK: - Configuration + + /// Legend layout style + public enum LegendStyle { + case horizontal + case vertical + case compact + } + + /// Legend position relative to parent + public enum LegendPosition { + case bottom + case top + case leading + case trailing + } + + // MARK: - Properties + + /// Color theme for the legend + let colorTheme: HeatmapColorTheme + + /// Maximum cost value for scale reference + let maxCost: Double + + /// Legend display style + let style: LegendStyle + + /// Font for legend text + let font: Font + + /// Whether to show cost labels + let showCostLabels: Bool + + /// Whether to show intensity labels (Less/More) + let showIntensityLabels: Bool + + /// Custom title for the legend + let customTitle: String? + + /// Accessibility configuration + private let accessibility: HeatmapAccessibility + + // MARK: - Initialization + + /// Initialize heatmap legend + /// - Parameters: + /// - colorTheme: Color theme to display + /// - maxCost: Maximum cost value for reference + /// - style: Display style (default: horizontal) + /// - font: Font for text (default: caption) + /// - showCostLabels: Whether to show cost values (default: true) + /// - showIntensityLabels: Whether to show Less/More labels (default: true) + /// - customTitle: Optional custom title + /// - accessibility: Accessibility configuration + public init( + colorTheme: HeatmapColorTheme, + maxCost: Double, + style: LegendStyle = .horizontal, + font: Font = .caption, + showCostLabels: Bool = true, + showIntensityLabels: Bool = true, + customTitle: String? = nil, + accessibility: HeatmapAccessibility = .default + ) { + self.colorTheme = colorTheme + self.maxCost = maxCost + self.style = style + self.font = font + self.showCostLabels = showCostLabels + self.showIntensityLabels = showIntensityLabels + self.customTitle = customTitle + self.accessibility = accessibility + } + + // MARK: - Body + + public var body: some View { + switch style { + case .horizontal: + horizontalLegend + case .vertical: + verticalLegend + case .compact: + compactLegend + } + } + + // MARK: - Legend Variants (Mid Level) + + @ViewBuilder + private var horizontalLegend: some View { + VStack(alignment: .leading, spacing: 4) { + legendTitle + horizontalLegendContent + } + .accessibilityElement(children: .combine) + .accessibilityLabel(legendAccessibilityLabel) + } + + @ViewBuilder + private var verticalLegend: some View { + VStack(alignment: .center, spacing: 8) { + legendTitle + verticalColorScale + conditionalCostReference + } + .accessibilityElement(children: .combine) + .accessibilityLabel(legendAccessibilityLabel) + } + + // MARK: - Legend Content (Mid Level) + + @ViewBuilder + private var horizontalLegendContent: some View { + HStack(spacing: 8) { + lessLabel + colorSquares + moreLabel + Spacer() + conditionalCostReference + } + } + + @ViewBuilder + private var verticalColorScale: some View { + VStack(spacing: 3) { + moreLabel + verticalColorSquares + lessLabel + } + } + + @ViewBuilder + private var verticalColorSquares: some View { + ForEach(Array((0..<5).reversed()), id: \.self) { level in + LegendSquare( + level: level, + accessibility: accessibility + ) + } + } + + @ViewBuilder + private var compactLegend: some View { + HStack(spacing: 4) { + colorSquares + compactCostLabel + } + .accessibilityElement(children: .combine) + .accessibilityLabel(compactAccessibilityLabel) + } + + // MARK: - Components (Low Level) + + @ViewBuilder + private var legendTitle: some View { + if let title = effectiveTitle { + Text(title) + .font(font.weight(.semibold)) + .foregroundColor(.primary) + .accessibilityAddTraits(.isHeader) + } + } + + @ViewBuilder + private var lessLabel: some View { + if showIntensityLabels { + Text("Less") + .font(font) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private var moreLabel: some View { + if showIntensityLabels { + Text("More") + .font(font) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private var colorSquares: some View { + HStack(spacing: 3) { + ForEach(0..<5, id: \.self) { level in + LegendSquare( + level: level, + accessibility: accessibility + ) + } + } + } + + @ViewBuilder + private var conditionalCostReference: some View { + if showCostLabels && maxCost > 0 { + costReference + } + } + + @ViewBuilder + private var costReference: some View { + VStack(alignment: .trailing, spacing: 2) { + Text("Max: \(maxCost.asCurrency)") + .font(font) + .foregroundColor(.secondary) + + if maxCost > 1 { + let quarterCost = maxCost * 0.25 + Text("~\(quarterCost.asCurrency) per level") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + private var compactCostLabel: some View { + if showCostLabels && maxCost > 0 { + Text("Max: \(maxCost.asCurrency)") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + + // MARK: - Computed Properties + + private var effectiveTitle: String? { + customTitle ?? (showCostLabels ? "Daily Cost Activity" : nil) + } + + // MARK: - Accessibility (Low Level) + + private var legendAccessibilityLabel: String { + guard accessibility.enableAccessibilityLabels else { return "" } + + var label = "Activity legend: " + label += "5 levels from no activity to high activity, " + + if showCostLabels && maxCost > 0 { + label += "maximum daily cost \(maxCost.asCurrency)" + } + + return label + } + + private var compactAccessibilityLabel: String { + guard accessibility.enableAccessibilityLabels else { return "" } + return "Activity scale with maximum cost \(maxCost.asCurrency)" + } +} + +// MARK: - Preview + +#if DEBUG +struct HeatmapLegend_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + HeatmapLegend( + colorTheme: .github, + maxCost: 15.42, + style: .horizontal + ) + + HeatmapLegend( + colorTheme: .ocean, + maxCost: 8.75, + style: .compact + ) + + HeatmapLegend( + colorTheme: .sunset, + maxCost: 23.15, + style: .vertical + ) + + Spacer() + } + .padding() + .background(Color(.controlBackgroundColor)) + } +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift new file mode 100644 index 0000000..5aaea72 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift @@ -0,0 +1,83 @@ +// +// HeatmapLegendBuilder.swift +// Builder pattern for creating customized legends +// + +import SwiftUI + +// MARK: - Legend Builder + +/// Builder pattern for creating customized legends +public struct HeatmapLegendBuilder: @unchecked Sendable { + private var colorTheme: HeatmapColorTheme = .github + private var maxCost: Double = 0 + private var style: HeatmapLegend.LegendStyle = .horizontal + private var font: Font = .caption + private var showCostLabels: Bool = true + private var showIntensityLabels: Bool = true + private var customTitle: String? + private var accessibility: HeatmapAccessibility = .default + + public init() {} + + public func colorTheme(_ theme: HeatmapColorTheme) -> Self { + var builder = self + builder.colorTheme = theme + return builder + } + + public func maxCost(_ cost: Double) -> Self { + var builder = self + builder.maxCost = cost + return builder + } + + public func style(_ legendStyle: HeatmapLegend.LegendStyle) -> Self { + var builder = self + builder.style = legendStyle + return builder + } + + public func font(_ legendFont: Font) -> Self { + var builder = self + builder.font = legendFont + return builder + } + + public func showCostLabels(_ show: Bool) -> Self { + var builder = self + builder.showCostLabels = show + return builder + } + + public func showIntensityLabels(_ show: Bool) -> Self { + var builder = self + builder.showIntensityLabels = show + return builder + } + + public func title(_ title: String?) -> Self { + var builder = self + builder.customTitle = title + return builder + } + + public func accessibility(_ config: HeatmapAccessibility) -> Self { + var builder = self + builder.accessibility = config + return builder + } + + public func build() -> HeatmapLegend { + HeatmapLegend( + colorTheme: colorTheme, + maxCost: maxCost, + style: style, + font: font, + showCostLabels: showCostLabels, + showIntensityLabels: showIntensityLabels, + customTitle: customTitle, + accessibility: accessibility + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift new file mode 100644 index 0000000..a1cc322 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift @@ -0,0 +1,44 @@ +// +// LegendSquare.swift +// Individual square component for legend visualization +// + +import SwiftUI + +// MARK: - Legend Square + +/// Individual square in the legend +struct LegendSquare: View { + let level: Int + let accessibility: HeatmapAccessibility + + @Environment(\.colorScheme) private var colorScheme + + private var squareColor: Color { + HeatmapColorScheme.color(for: level, scheme: colorScheme) + } + + var body: some View { + Rectangle() + .fill(squareColor) + .frame(width: 11, height: 11) + .cornerRadius(2) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } + + private var accessibilityLabel: String { + guard accessibility.enableAccessibilityLabels else { return "" } + return ActivityLevelLabels.label(for: level) + } + + private var accessibilityValue: String { + guard accessibility.enableAccessibilityValues else { return "" } + return "Level \(level) of 4" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift new file mode 100644 index 0000000..a984d93 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift @@ -0,0 +1,43 @@ +// +// ActivityLevel.swift +// Activity level classification for tooltip display +// + +import SwiftUI + +// MARK: - Activity Level (Pure Data) + +/// Activity level classification based on intensity +enum ActivityLevel { + case none, low, medium, high, veryHigh + + init(intensity: Double) { + switch intensity { + case 0: self = .none + case ..<0.25: self = .low + case ..<0.5: self = .medium + case ..<0.75: self = .high + default: self = .veryHigh + } + } + + var text: String { + switch self { + case .none: "None" + case .low: "Low" + case .medium: "Medium" + case .high: "High" + case .veryHigh: "Very High" + } + } + + var color: Color { + switch self { + case .none: .gray + case .low: .green.opacity(0.7) + case .medium: .green + case .high: .orange + case .veryHigh: .red + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift new file mode 100644 index 0000000..40b2679 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift @@ -0,0 +1,22 @@ +// +// ActivityLevelLabels.swift +// Pure data for activity level accessibility labels +// + +import Foundation + +// MARK: - Activity Level Labels (Pure Data) + +enum ActivityLevelLabels { + static let labels = [ + "No activity", + "Low activity", + "Medium-low activity", + "Medium-high activity", + "High activity" + ] + + static func label(for level: Int) -> String { + labels.indices.contains(level) ? labels[level] : "Activity level \(level)" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift new file mode 100644 index 0000000..0355fe0 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift @@ -0,0 +1,25 @@ +// +// BorderStyle.swift +// Pure data struct for day square border styling +// + +import SwiftUI + +// MARK: - Border Style (Pure Data) + +struct BorderStyle { + let color: Color + let width: CGFloat + + static let none = BorderStyle(color: .clear, width: 0) + + static func forDay(_ day: HeatmapDay, isHovered: Bool, config: HeatmapConfiguration) -> BorderStyle { + if day.isToday { + return BorderStyle(color: config.todayHighlightColor, width: config.todayHighlightWidth) + } else if isHovered { + return BorderStyle(color: .primary, width: 1) + } else { + return .none + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift new file mode 100644 index 0000000..8766298 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift @@ -0,0 +1,352 @@ +// +// ColorScheme.swift +// Advanced color management for heatmap visualization +// +// Provides sophisticated color calculations, theme management, and performance-optimized +// color caching for heatmap components with accessibility support. +// + +import SwiftUI +import Foundation + +// MARK: - Color Manager + +/// Advanced color management for heatmap visualizations +public final class HeatmapColorManager: @unchecked Sendable { + + // MARK: - Singleton + + public static let shared = HeatmapColorManager() + private init() {} + + // MARK: - Types + + /// Different color variations for special states + public enum ColorVariation: String, CaseIterable { + case normal = "normal" + case hovered = "hovered" + case selected = "selected" + case dimmed = "dimmed" + case highlighted = "highlighted" + } + + // MARK: - Public Interface (High Level) + + /// Get color for a cost value with caching + public func color( + for cost: Double, + maxCost: Double, + theme: HeatmapColorTheme = .github, + variation: ColorVariation = .normal + ) -> Color { + cachedColor(for: cost, maxCost: maxCost, theme: theme, variation: variation) + ?? computeAndCacheColor(for: cost, maxCost: maxCost, theme: theme, variation: variation) + } + + /// Pre-calculate colors for a range of values (performance optimization) + public func preCalculateColors( + for costs: [Double], + maxCost: Double, + theme: HeatmapColorTheme = .github + ) -> [Double: Color] { + costs.reduce(into: [:]) { colorMap, cost in + colorMap[cost] = color(for: cost, maxCost: maxCost, theme: theme) + } + } + + /// Analyze color distribution for a dataset + public func analyzeColorDistribution(costs: [Double], maxCost: Double) -> ColorDistribution { + let levelCounts = countCostsByLevel(costs, maxCost: maxCost) + let averageCost = calculateAverageCost(costs) + + return ColorDistribution( + totalItems: costs.count, + levelCounts: levelCounts, + maxCost: maxCost, + averageCost: averageCost + ) + } + + /// Clear color cache to free memory + public func clearCache() { + colorCache.removeAll() + } + + // MARK: - Color Calculation (Mid Level) + + private func computeAndCacheColor( + for cost: Double, + maxCost: Double, + theme: HeatmapColorTheme, + variation: ColorVariation + ) -> Color { + let baseColor = calculateBaseColor(cost: cost, maxCost: maxCost, theme: theme) + let finalColor = applyVariation(baseColor, variation: variation) + + storeInCache(finalColor, for: cost, maxCost: maxCost, theme: theme, variation: variation) + + return finalColor + } + + private func calculateBaseColor(cost: Double, maxCost: Double, theme: HeatmapColorTheme) -> Color { + if cost == 0 { return theme.colors[0] } + + let intensity = calculateIntensity(cost: cost, maxCost: maxCost) + let levelIndex = IntensityLevel.fromIntensity(intensity) + + return theme.colors[levelIndex] + } + + private func calculateIntensity(cost: Double, maxCost: Double) -> Double { + maxCost > 0 ? min(cost / maxCost, 1.0) : 0.0 + } + + private func countCostsByLevel(_ costs: [Double], maxCost: Double) -> [Int] { + costs.reduce(into: [0, 0, 0, 0, 0]) { counts, cost in + let intensity = calculateIntensity(cost: cost, maxCost: maxCost) + let level = IntensityLevel.fromIntensity(intensity) + counts[level] += 1 + } + } + + private func calculateAverageCost(_ costs: [Double]) -> Double { + costs.isEmpty ? 0 : costs.reduce(0, +) / Double(costs.count) + } + + // MARK: - Caching (Infrastructure) + + private var colorCache: [ColorCacheKey: Color] = [:] + + private struct ColorCacheKey: Hashable { + let cost: Double + let maxCost: Double + let theme: HeatmapColorTheme + let variation: ColorVariation + + func hash(into hasher: inout Hasher) { + hasher.combine(cost) + hasher.combine(maxCost) + hasher.combine(theme.rawValue) + hasher.combine(variation.rawValue) + } + } + + private func cachedColor( + for cost: Double, + maxCost: Double, + theme: HeatmapColorTheme, + variation: ColorVariation + ) -> Color? { + let cacheKey = ColorCacheKey(cost: cost, maxCost: maxCost, theme: theme, variation: variation) + return colorCache[cacheKey] + } + + private func storeInCache( + _ color: Color, + for cost: Double, + maxCost: Double, + theme: HeatmapColorTheme, + variation: ColorVariation + ) { + let cacheKey = ColorCacheKey(cost: cost, maxCost: maxCost, theme: theme, variation: variation) + colorCache[cacheKey] = color + } + + // MARK: - Color Variations (Low Level) + + private func applyVariation(_ baseColor: Color, variation: ColorVariation) -> Color { + switch variation { + case .normal: + return baseColor + case .hovered: + return baseColor.opacity(0.8) + case .selected: + return baseColor.brightness(0.2) + case .dimmed: + return baseColor.opacity(0.5) + case .highlighted: + return baseColor.saturation(1.3) + } + } +} + +// MARK: - Color Distribution + +/// Statistics about color distribution in a heatmap +public struct ColorDistribution: Sendable { + public let totalItems: Int + public let levelCounts: [Int] // Count for each of the 5 color levels + public let maxCost: Double + public let averageCost: Double + + /// Percentage distribution across levels + public var levelPercentages: [Double] { + guard totalItems > 0 else { return Array(repeating: 0, count: 5) } + return levelCounts.map { Double($0) / Double(totalItems) * 100 } + } + + /// Most common color level + public var dominantLevel: Int { + guard let maxIndex = levelCounts.enumerated().max(by: { $0.element < $1.element })?.offset else { + return 0 + } + return maxIndex + } +} + +// MARK: - Accessibility Colors + +/// Accessibility-friendly color management +public struct AccessibilityColorScheme { + + /// High contrast color scheme for better accessibility + public static let highContrast = HeatmapColorTheme.custom(colors: [ + Color.black.opacity(0.1), // Empty - very light + Color.blue.opacity(0.4), // Low - medium blue + Color.blue.opacity(0.6), // Medium-low - darker blue + Color.blue.opacity(0.8), // Medium-high - dark blue + Color.blue // High - full blue + ]) + + /// Colorblind-friendly color scheme (safe for deuteranopia/protanopia) + public static let colorblindFriendly = HeatmapColorTheme.custom(colors: [ + Color.gray.opacity(0.3), // Empty + Color.blue.opacity(0.3), // Low - blue instead of green + Color.blue.opacity(0.5), // Medium-low + Color.blue.opacity(0.7), // Medium-high + Color.blue // High + ]) + + /// Monochrome scheme for ultimate accessibility + public static let monochrome = HeatmapColorTheme.custom(colors: [ + Color(white: 0.9), // Empty - very light gray + Color(white: 0.7), // Low + Color(white: 0.5), // Medium-low + Color(white: 0.3), // Medium-high + Color(white: 0.1) // High - very dark gray + ]) +} + +// MARK: - Dynamic Color Themes + +public extension HeatmapColorTheme { + + /// Create a custom color theme + /// - Parameter colors: Array of 5 colors for the theme levels + /// - Returns: Custom color theme + static func custom(colors: [Color]) -> HeatmapColorTheme { + // This would require modifying the enum to support custom themes + // For now, we'll return a default theme + return .github + } + + /// Generate a color theme based on a base color + /// - Parameter baseColor: The primary color to base the theme on + /// - Returns: Generated color theme + static func generate(from baseColor: Color) -> [Color] { + return [ + Color(red: 240/255, green: 242/255, blue: 245/255), // Empty + baseColor.opacity(0.35), // Low + baseColor.opacity(0.55), // Medium-low + baseColor.opacity(0.75), // Medium-high + baseColor.opacity(0.95) // High + ] + } + + /// Check if a color theme provides sufficient contrast + /// - Parameter theme: The theme to check + /// - Returns: True if the theme has good contrast + func hasGoodContrast() -> Bool { + // Simplified contrast check - in a real implementation, + // you would calculate WCAG contrast ratios + let colors = self.colors + guard colors.count >= 2 else { return false } + + // Check that there's sufficient difference between empty and high + // This is a simplified check - proper implementation would use luminance + return true + } +} + +// MARK: - Performance Optimizations + +/// Performance-optimized color utilities +public struct HeatmapColorPerformance: Sendable { + + /// Pre-computed color lookup table for common cost ranges + public static func buildColorLUT( + maxCost: Double, + steps: Int = 100, + theme: HeatmapColorTheme = .github + ) -> [Color] { + (0...steps).map { i in + let cost = Double(i) / Double(steps) * maxCost + return HeatmapColorManager.shared.color(for: cost, maxCost: maxCost, theme: theme) + } + } + + /// Get color from lookup table for performance + /// - Parameters: + /// - cost: Cost value + /// - maxCost: Maximum cost + /// - lut: Pre-computed lookup table + /// - Returns: Color from lookup table + public static func colorFromLUT( + cost: Double, + maxCost: Double, + lut: [Color] + ) -> Color { + guard maxCost > 0, !lut.isEmpty else { return HeatmapColorTheme.github.colors[0] } + + let normalizedCost = min(cost / maxCost, 1.0) + let index = Int(normalizedCost * Double(lut.count - 1)) + let clampedIndex = max(0, min(lut.count - 1, index)) + + return lut[clampedIndex] + } +} + +// MARK: - Color Utilities + +public extension Color { + + /// Adjust brightness of a color + /// - Parameter amount: Brightness adjustment (-1.0 to 1.0) + /// - Returns: Color with adjusted brightness + func brightness(_ amount: Double) -> Color { + // This is a simplified implementation + // Real implementation would convert to HSB, adjust, and convert back + return self.opacity(max(0, min(1, 1.0 + amount))) + } + + /// Adjust saturation of a color + /// - Parameter amount: Saturation multiplier + /// - Returns: Color with adjusted saturation + func saturation(_ amount: Double) -> Color { + // Simplified implementation + return self + } + + /// Get hex representation of color (useful for debugging) + var hexString: String { + // Simplified - real implementation would extract RGB components + return "#000000" + } +} + +// MARK: - Intensity Level Calculation (Pure Functions) + +private enum IntensityLevel { + /// Intensity thresholds for each level + static let thresholds: [(range: PartialRangeFrom, level: Int)] = [ + (0.75..., 4), // High + (0.5..., 3), // Medium-high + (0.25..., 2), // Medium-low + (0.0..., 1) // Low (anything > 0) + ] + + /// Convert intensity (0.0-1.0) to discrete level (0-4) + static func fromIntensity(_ intensity: Double) -> Int { + intensity == 0 ? 0 : (thresholds.first { $0.range.contains(intensity) }?.level ?? 1) + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift new file mode 100644 index 0000000..c129b94 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift @@ -0,0 +1,13 @@ +// +// DateConstants.swift +// Constants for heatmap date calculations +// + +import Foundation + +enum DateConstants { + static let daysPerWeek = 7 + static let defaultRollingDays = 365 + static let maxDaysForPerformance = 400 + static let minDaysRequired = 1 +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift new file mode 100644 index 0000000..e337057 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift @@ -0,0 +1,66 @@ +// +// DateRangeValidation.swift +// Date range validation and utilities +// + +import Foundation + +// MARK: - Validation Rules + +enum DateRangeValidation { + + /// All validation rules as closures that return optional error message + static func rules(calendar: Calendar) -> [(Date, Date) -> String?] { + [ + { start, end in + start > end ? "Start date cannot be after end date" : nil + }, + { start, end in + let days = calendar.dateComponents([.day], from: start, to: end).day ?? 0 + return days > DateConstants.maxDaysForPerformance + ? "Date range too large (maximum \(DateConstants.maxDaysForPerformance) days for performance)" + : nil + }, + { start, end in + let days = calendar.dateComponents([.day], from: start, to: end).day ?? 0 + return days < DateConstants.minDaysRequired + ? "Date range too small (minimum \(DateConstants.minDaysRequired) day)" + : nil + }, + { start, _ in + let oneYearFromNow = calendar.date(byAdding: .year, value: 1, to: Date())! + return start > oneYearFromNow ? "Start date is too far in the future" : nil + } + ] + } + + /// Validate date range and return all errors + static func validate(start: Date, end: Date, calendar: Calendar) -> [String] { + rules(calendar: calendar).compactMap { $0(start, end) } + } +} + +// MARK: - Date Range Extensions + +public extension ClosedRange where Bound == Date { + + /// Whether the range contains today + var containsToday: Bool { + let today = Calendar.current.startOfDay(for: Date()) + return self.contains(today) + } + + /// Number of days in the range + var dayCount: Int { + let calendar = Calendar.current + let startDay = calendar.startOfDay(for: lowerBound) + let endDay = calendar.startOfDay(for: upperBound) + let components = calendar.dateComponents([.day], from: startDay, to: endDay) + return (components.day ?? 0) + 1 + } + + /// Array of all dates in the range + var allDates: [Date] { + HeatmapDateCalculator.shared.dateSequence(from: lowerBound, to: upperBound) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift new file mode 100644 index 0000000..09eaa57 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift @@ -0,0 +1,347 @@ +// +// HeatmapData.swift +// Data models for heatmap visualization +// +// Provides type-safe data structures for representing heatmap information +// with optimized caching and pre-computed values for performance. +// + +import SwiftUI +import Foundation + +// MARK: - Core Data Models + +/// Represents a single day in the heatmap with pre-computed display values for performance +public struct HeatmapDay: Identifiable, Equatable, Hashable { + + // MARK: - Identification & Core Data + + /// Stable date-based identifier to prevent SwiftUI view recreation + public let id: String + + /// The date this day represents + public let date: Date + + /// The cost value for this day + public let cost: Double + + // MARK: - Calendar Properties + + /// Day of the year (1-366) + public let dayOfYear: Int + + /// Week number within the heatmap + public let weekOfYear: Int + + /// Day of the week (0=Sunday, 6=Saturday) + public let dayOfWeek: Int + + // MARK: - Display Properties + + /// Whether this day has no usage (cost == 0) + public let isEmpty: Bool + + /// Whether this day is today + public let isToday: Bool + + /// Pre-formatted date string for display (e.g., "Jan 15, 2024") + public let dateString: String + + /// Pre-formatted cost string for display (e.g., "$1.23" or "No usage") + public let costString: String + + /// Pre-computed color for light mode (legacy - use color(for:) for scheme-aware color) + public let color: Color + + /// Intensity level (0.0 to 1.0) relative to the maximum cost + public let intensity: Double + + /// Discrete intensity level (0-4) for color lookup + public let intensityLevel: Int + + // MARK: - Initialization + + public init( + date: Date, + cost: Double, + dayOfYear: Int, + weekOfYear: Int, + dayOfWeek: Int, + maxCost: Double + ) { + self.id = HeatmapDateFormatter.stableIdentifier(for: date) + self.date = date + self.cost = cost + self.dayOfYear = dayOfYear + self.weekOfYear = weekOfYear + self.dayOfWeek = dayOfWeek + + self.isEmpty = cost == 0 + self.isToday = HeatmapDateFormatter.isToday(date) + self.dateString = HeatmapDateFormatter.displayString(for: date) + self.costString = cost > 0 ? cost.asCurrency : "No usage" + + self.intensity = IntensityLevelCalculator.intensity(cost: cost, maxCost: maxCost) + self.intensityLevel = IntensityLevelCalculator.level(for: self.intensity) + self.color = HeatmapColorScheme.color(for: cost, maxCost: maxCost) + } + + // MARK: - Color Scheme Support + + /// Returns the appropriate color for the given color scheme + public func color(for scheme: ColorScheme) -> Color { + HeatmapColorScheme.color(for: intensityLevel, scheme: scheme) + } + + // MARK: - Equatable & Hashable + + public static func == (lhs: HeatmapDay, rhs: HeatmapDay) -> Bool { + lhs.id == rhs.id && lhs.cost == rhs.cost + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(cost) + } +} + +/// Represents a week in the heatmap containing up to 7 days +public struct HeatmapWeek: Identifiable, Equatable { + + /// Stable week-based identifier + public let id: String + + /// Week number within the heatmap + public let weekNumber: Int + + /// Array of 7 days (some may be nil for partial weeks) + public let days: [HeatmapDay?] + + /// Total cost for this week + public let totalCost: Double + + /// Number of days with usage in this week + public let daysWithUsage: Int + + public init(weekNumber: Int, days: [HeatmapDay?]) { + self.id = "week-\(weekNumber)" + self.weekNumber = weekNumber + self.days = days + + // Calculate derived properties + self.totalCost = days.compactMap { $0?.cost }.reduce(0, +) + self.daysWithUsage = days.compactMap { $0 }.filter { !$0.isEmpty }.count + } +} + +/// Represents a month label in the heatmap +public struct HeatmapMonth: Identifiable, Equatable { + + /// Stable month-based identifier + public let id: String + + /// Short month name (e.g., "Jan", "Feb") + public let name: String + + /// Range of week indices this month spans + public let weekSpan: Range + + /// Full month name (e.g., "January", "February") + public let fullName: String + + /// Month number (1-12) + public let monthNumber: Int + + /// Year this month belongs to + public let year: Int + + public init(name: String, weekSpan: Range, fullName: String = "", monthNumber: Int = 0, year: Int = 0) { + self.id = "month-\(name)-\(year)" + self.name = name + self.weekSpan = weekSpan + self.fullName = fullName.isEmpty ? name : fullName + self.monthNumber = monthNumber + self.year = year + } +} + +/// Complete dataset for heatmap visualization +public struct HeatmapDataset: Equatable { + + /// Array of weeks containing the heatmap data + public let weeks: [HeatmapWeek] + + /// Array of month labels for the header + public let monthLabels: [HeatmapMonth] + + /// Maximum cost value across all days (used for color scaling) + public let maxCost: Double + + /// Date range covered by this dataset + public let dateRange: ClosedRange + + /// Total cost across all days + public let totalCost: Double + + /// Total number of days with usage + public let daysWithUsage: Int + + /// All days flattened from weeks (excluding nil days) + public var allDays: [HeatmapDay] { + weeks.flatMap { $0.days.compactMap { $0 } } + } + + public init( + weeks: [HeatmapWeek], + monthLabels: [HeatmapMonth], + maxCost: Double, + dateRange: ClosedRange + ) { + self.weeks = weeks + self.monthLabels = monthLabels + self.maxCost = maxCost + self.dateRange = dateRange + + // Calculate derived properties + let allDays = weeks.flatMap { $0.days.compactMap { $0 } } + self.totalCost = allDays.reduce(0) { $0 + $1.cost } + self.daysWithUsage = allDays.filter { !$0.isEmpty }.count + } +} + +// MARK: - Supporting Types + +// MARK: Date Formatting Helpers + +private enum HeatmapDateFormatter { + private static let idFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + return formatter + }() + + static func stableIdentifier(for date: Date) -> String { + idFormatter.string(from: date) + } + + static func displayString(for date: Date) -> String { + displayFormatter.string(from: date) + } + + static func isToday(_ date: Date) -> Bool { + Calendar.current.isDateInToday(date) + } +} + +// MARK: Intensity Level Calculation (Pure Functions) + +private enum IntensityLevelCalculator { + /// Thresholds for each level (checked in order) + static let thresholds: [(range: PartialRangeFrom, level: Int)] = [ + (0.75..., 4), // High + (0.5..., 3), // Medium-high + (0.25..., 2), // Medium-low + (0.0..., 1) // Low (anything > 0) + ] + + /// Convert intensity (0.0-1.0) to discrete level (0-4) + static func level(for intensity: Double) -> Int { + intensity == 0 ? 0 : (thresholds.first { $0.range.contains(intensity) }?.level ?? 1) + } + + /// Calculate intensity from cost + static func intensity(cost: Double, maxCost: Double) -> Double { + maxCost > 0 ? min(cost / maxCost, 1.0) : 0.0 + } +} + +// MARK: Color Scheme + +/// Optimized color scheme for heatmap visualization with light/dark mode support +public enum HeatmapColorScheme { + + // MARK: - Light Mode Colors (GitHub Light Theme) + + /// Colors for light mode (5 levels from no activity to high activity) + public static let lightColors: [Color] = [ + Color(red: 235/255, green: 237/255, blue: 240/255), // Level 0: #ebedf0 + Color(red: 155/255, green: 233/255, blue: 168/255), // Level 1: #9be9a8 + Color(red: 64/255, green: 196/255, blue: 99/255), // Level 2: #40c463 + Color(red: 48/255, green: 161/255, blue: 78/255), // Level 3: #30a14e + Color(red: 33/255, green: 110/255, blue: 57/255) // Level 4: #216e39 + ] + + // MARK: - Dark Mode Colors (GitHub Dark Theme) + + /// Colors for dark mode (5 levels from no activity to high activity) + public static let darkColors: [Color] = [ + Color(red: 22/255, green: 27/255, blue: 34/255), // Level 0: #161b22 + Color(red: 14/255, green: 68/255, blue: 41/255), // Level 1: #0e4429 + Color(red: 0/255, green: 109/255, blue: 50/255), // Level 2: #006d32 + Color(red: 38/255, green: 166/255, blue: 65/255), // Level 3: #26a641 + Color(red: 57/255, green: 211/255, blue: 83/255) // Level 4: #39d353 + ] + + // MARK: - Legacy Support + + /// Legacy color array (light mode) - kept for backward compatibility + @available(*, deprecated, message: "Use colors(for:) with ColorScheme instead") + public static var legendColors: [Color] { lightColors } + + // MARK: - Color Calculation + + /// Returns colors for the specified color scheme + public static func colors(for scheme: ColorScheme) -> [Color] { + scheme == .dark ? darkColors : lightColors + } + + /// Returns the appropriate color for a given level and color scheme + public static func color(for level: Int, scheme: ColorScheme) -> Color { + let colors = colors(for: scheme) + let index = max(0, min(colors.count - 1, level)) + return colors[index] + } + + /// Returns the appropriate color for a given cost value (light mode - legacy) + public static func color(for cost: Double, maxCost: Double) -> Color { + let level = intensityLevel(for: cost, maxCost: maxCost) + return lightColors[level] + } + + /// Returns the appropriate color for a given cost value and color scheme + public static func color(for cost: Double, maxCost: Double, scheme: ColorScheme) -> Color { + let level = intensityLevel(for: cost, maxCost: maxCost) + return color(for: level, scheme: scheme) + } + + /// Returns the intensity level (0-4) for legend purposes + public static func intensityLevel(for cost: Double, maxCost: Double) -> Int { + let intensity = IntensityLevelCalculator.intensity(cost: cost, maxCost: maxCost) + return IntensityLevelCalculator.level(for: intensity) + } +} + +// MARK: - Extensions + +/// Safe array access extension +public extension Array { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +/// Double extension for currency formatting +public extension Double { + var asCurrency: String { + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale.current + return formatter.string(from: NSNumber(value: self)) ?? "$0.00" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift new file mode 100644 index 0000000..a98c554 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift @@ -0,0 +1,301 @@ +// +// HeatmapDateCalculator.swift +// Main calculator for heatmap date operations +// + +import Foundation + +/// Utility class for date calculations in heatmap generation +public final class HeatmapDateCalculator: @unchecked Sendable { + + // MARK: - Singleton + + public static let shared = HeatmapDateCalculator() + private init() {} + + // MARK: - Cached Formatters + + private lazy var idFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone.current + formatter.locale = Locale.current + return formatter + }() + + private lazy var displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + formatter.timeZone = TimeZone.current + formatter.locale = Locale.current + return formatter + }() + + private lazy var calendar: Calendar = { + var cal = Calendar.current + cal.timeZone = TimeZone.current + return cal + }() + + // MARK: - Public Types + + /// Information about a month for labeling + public struct MonthInfo { + public let name: String + public let fullName: String + public let monthNumber: Int + public let year: Int + public let firstWeek: Int + public let lastWeek: Int + + public var weekSpan: Range { + firstWeek..<(lastWeek + 1) + } + } + + // MARK: - Public Interface (High Level) + + /// Rolling date range ending on given date + public func rollingDateRange( + endingOn endDate: Date = Date(), + numberOfDays: Int = 365 + ) -> (start: Date, end: Date) { + let end = calendar.startOfDay(for: endDate) + let start = calendar.date(byAdding: .day, value: -(numberOfDays - 1), to: end)! + return (start: start, end: end) + } + + /// Rolling date range with complete weeks, avoiding duplicate months + public func rollingDateRangeWithCompleteWeeks( + endingOn endDate: Date = Date(), + numberOfDays: Int = 365 + ) -> (start: Date, end: Date) { + let end = calendar.startOfDay(for: endDate) + let initialStart = calendar.date(byAdding: .day, value: -(numberOfDays - 1), to: end)! + let adjustedStart = MonthOps.adjustStartForSameMonth(start: initialStart, end: end, calendar: calendar) + let finalStart = WeekOps.adjustToCompleteWeek(adjustedStart, calendar: calendar) + return (start: finalStart, end: end) + } + + /// Sunday of the week containing given date + public func weekStart(for date: Date) -> Date { + WeekOps.weekStart(for: date, calendar: calendar) + } + + /// Number of weeks in date range + public func weeksInRange(from startDate: Date, to endDate: Date) -> Int { + let start = weekStart(for: startDate) + let daysBetween = calendar.dateComponents([.day], from: start, to: endDate).day ?? 0 + return (daysBetween / DateConstants.daysPerWeek) + 1 + } + + /// Calendar properties for a date + public func calendarProperties(for date: Date) -> (dayOfYear: Int, weekOfYear: Int, dayOfWeek: Int) { + ( + dayOfYear: calendar.ordinality(of: .day, in: .year, for: date) ?? 0, + weekOfYear: calendar.component(.weekOfYear, from: date), + dayOfWeek: calendar.component(.weekday, from: date) - 1 + ) + } + + /// Check if date is today + public func isToday(_ date: Date) -> Bool { + calendar.startOfDay(for: date) == calendar.startOfDay(for: Date()) + } + + /// Format date as ID string (yyyy-MM-dd) + public func formatDateAsID(_ date: Date) -> String { + idFormatter.string(from: date) + } + + /// Format date for display (MMM d, yyyy) + public func formatDateForDisplay(_ date: Date) -> String { + displayFormatter.string(from: date) + } + + /// Generate month labels for date range + public func generateMonthLabels(from startDate: Date, to endDate: Date) -> [MonthInfo] { + var months: [MonthInfo] = [] + var state = createInitialMonthTrackingState(for: startDate) + + enumerateWeeks(from: startDate, to: endDate) { weekIndex, weekStart in + processWeekForMonthTransition( + weekIndex: weekIndex, + weekStart: weekStart, + dateRange: startDate...endDate, + state: &state, + months: &months + ) + } + + finalizeMonthLabels(state: state, months: &months) + return months + } + + /// Generate sequence of dates between two dates + public func dateSequence(from startDate: Date, to endDate: Date) -> [Date] { + let start = calendar.startOfDay(for: startDate) + let end = calendar.startOfDay(for: endDate) + + return Array( + sequence(first: start) { self.calendar.date(byAdding: .day, value: 1, to: $0) } + .prefix { $0 <= end } + ) + } + + /// Generate weeks layout for heatmap + public func generateWeeksLayout(from startDate: Date, to endDate: Date) -> [[Date?]] { + let firstWeek = findFirstCompleteWeekStart(for: startDate) + return buildWeeksArray(from: firstWeek, to: endDate) + } + + // MARK: - Month Label Generation (Mid Level) + + private struct MonthTrackingState { + var currentMonth: Int + var currentYear: Int + var monthStartWeek: Int + var weekIndex: Int + } + + private func createInitialMonthTrackingState(for startDate: Date) -> MonthTrackingState { + MonthTrackingState( + currentMonth: calendar.component(.month, from: startDate), + currentYear: calendar.component(.year, from: startDate), + monthStartWeek: 0, + weekIndex: 0 + ) + } + + private func enumerateWeeks( + from startDate: Date, + to endDate: Date, + handler: (Int, Date) -> Void + ) { + WeekOps.weekSequence(from: startDate, calendar: calendar) + .prefix { $0 <= endDate } + .enumerated() + .forEach { handler($0.offset, $0.element) } + } + + private func processWeekForMonthTransition( + weekIndex: Int, + weekStart: Date, + dateRange: ClosedRange, + state: inout MonthTrackingState, + months: inout [MonthInfo] + ) { + state.weekIndex = weekIndex + + guard let firstVisibleDay = WeekOps.firstVisibleDay(in: weekStart, within: dateRange, calendar: calendar) else { + return + } + + let dayMonth = calendar.component(.month, from: firstVisibleDay) + let dayYear = calendar.component(.year, from: firstVisibleDay) + + if MonthOps.hasMonthChanged(from: (state.currentMonth, state.currentYear), to: (dayMonth, dayYear)) { + appendCompletedMonth(state: state, to: &months) + state.currentMonth = dayMonth + state.currentYear = dayYear + state.monthStartWeek = weekIndex + } + } + + private func finalizeMonthLabels(state: MonthTrackingState, months: inout [MonthInfo]) { + let finalWeekIndex = state.weekIndex + 1 + guard finalWeekIndex > state.monthStartWeek else { return } + + let monthAbbrev = MonthOps.abbreviatedName(for: state.currentMonth, calendar: calendar) + let isDuplicateOfFirst = months.first?.name == monthAbbrev && + months.first?.monthNumber == state.currentMonth + + if isDuplicateOfFirst { + extendFirstMonthToIncludeFinalWeeks(finalWeekIndex: finalWeekIndex - 1, months: &months) + } else { + appendFinalMonth(state: state, lastWeek: finalWeekIndex - 1, to: &months) + } + } + + // MARK: - Week Layout Generation (Mid Level) + + private func findFirstCompleteWeekStart(for startDate: Date) -> Date { + let weekStartDate = WeekOps.weekStart(for: startDate, calendar: calendar) + + if WeekOps.isPartialWeek(weekStart: weekStartDate, rangeStart: startDate, calendar: calendar) { + return calendar.date(byAdding: .weekOfYear, value: 1, to: weekStartDate)! + } + return weekStartDate + } + + private func buildWeeksArray(from weekStart: Date, to endDate: Date) -> [[Date?]] { + Array( + WeekOps.weekSequence(from: weekStart, calendar: calendar) + .prefix { $0 <= endDate } + .map { WeekOps.weekDays(from: $0, calendar: calendar) } + ) + } + + // MARK: - Month Info Construction (Low Level) + + private func appendCompletedMonth(state: MonthTrackingState, to months: inout [MonthInfo]) { + guard !months.isEmpty || state.weekIndex > 0 else { return } + + months.append(createMonthInfo( + month: state.currentMonth, + year: state.currentYear, + firstWeek: state.monthStartWeek, + lastWeek: state.weekIndex - 1 + )) + } + + private func appendFinalMonth(state: MonthTrackingState, lastWeek: Int, to months: inout [MonthInfo]) { + months.append(createMonthInfo( + month: state.currentMonth, + year: state.currentYear, + firstWeek: state.monthStartWeek, + lastWeek: lastWeek + )) + } + + private func extendFirstMonthToIncludeFinalWeeks(finalWeekIndex: Int, months: inout [MonthInfo]) { + guard let firstMonth = months.first else { return } + + months[0] = MonthInfo( + name: firstMonth.name, + fullName: firstMonth.fullName, + monthNumber: firstMonth.monthNumber, + year: firstMonth.year, + firstWeek: firstMonth.firstWeek, + lastWeek: finalWeekIndex + ) + } + + private func createMonthInfo(month: Int, year: Int, firstWeek: Int, lastWeek: Int) -> MonthInfo { + MonthInfo( + name: MonthOps.abbreviatedName(for: month, calendar: calendar), + fullName: MonthOps.fullName(for: month, calendar: calendar), + monthNumber: month, + year: year, + firstWeek: firstWeek, + lastWeek: lastWeek + ) + } +} + +// MARK: - Date Validation + +public extension HeatmapDateCalculator { + + /// Validates that a date range is suitable for heatmap display + func validateDateRange(startDate: Date, endDate: Date) -> [String] { + var cal = Calendar.current + cal.timeZone = TimeZone.current + return DateRangeValidation.validate(start: startDate, end: endDate, calendar: cal) + } + + /// Whether a date range is valid for heatmap display + func isValidDateRange(startDate: Date, endDate: Date) -> Bool { + validateDateRange(startDate: startDate, endDate: endDate).isEmpty + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift new file mode 100644 index 0000000..156ba43 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift @@ -0,0 +1,40 @@ +// +// MonthOperations.swift +// Pure functions for month-based date calculations +// + +import Foundation + +enum MonthOps { + + /// Check if month/year combination has changed + static func hasMonthChanged( + from current: (month: Int, year: Int), + to new: (month: Int, year: Int) + ) -> Bool { + new.month != current.month || new.year != current.year + } + + /// Get abbreviated month name (3 chars) + static func abbreviatedName(for month: Int, calendar: Calendar) -> String { + String(calendar.monthSymbols[month - 1].prefix(3)) + } + + /// Get full month name + static func fullName(for month: Int, calendar: Calendar) -> String { + calendar.monthSymbols[month - 1] + } + + /// Adjust start date to next month if start and end are in same month + static func adjustStartForSameMonth(start: Date, end: Date, calendar: Calendar) -> Date { + let startMonth = calendar.component(.month, from: start) + let endMonth = calendar.component(.month, from: end) + + guard startMonth == endMonth else { return start } + + var components = calendar.dateComponents([.year, .month], from: start) + components.month! += 1 + components.day = 1 + return calendar.date(from: components)! + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift new file mode 100644 index 0000000..d93c756 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift @@ -0,0 +1,56 @@ +// +// WeekOperations.swift +// Pure functions for week-based date calculations +// + +import Foundation + +enum WeekOps { + + /// Find Sunday of the week containing the given date + static func weekStart(for date: Date, calendar: Calendar) -> Date { + let startOfDay = calendar.startOfDay(for: date) + let weekday = calendar.component(.weekday, from: startOfDay) + let daysToSunday = weekday - 1 + return calendar.date(byAdding: .day, value: -daysToSunday, to: startOfDay)! + } + + /// Check if week start is before range start (partial week) + static func isPartialWeek(weekStart: Date, rangeStart: Date, calendar: Calendar) -> Bool { + guard weekStart < rangeStart else { return false } + let daysBefore = calendar.dateComponents([.day], from: weekStart, to: rangeStart).day ?? 0 + return daysBefore > 0 + } + + /// Advance to next complete week if current is partial + static func adjustToCompleteWeek(_ date: Date, calendar: Calendar) -> Date { + let weekStartDate = weekStart(for: date, calendar: calendar) + if weekStartDate < date { + return calendar.date(byAdding: .weekOfYear, value: 1, to: weekStartDate)! + } + return weekStartDate + } + + /// Generate sequence of week start dates + static func weekSequence(from startDate: Date, calendar: Calendar) -> UnfoldFirstSequence { + let firstWeek = weekStart(for: startDate, calendar: calendar) + return sequence(first: firstWeek) { current in + calendar.date(byAdding: .weekOfYear, value: 1, to: current) + } + } + + /// Build array of 7 dates for a week + static func weekDays(from weekStart: Date, calendar: Calendar) -> [Date?] { + (0.., calendar: Calendar) -> Date? { + (0.. HeatmapDataset { + let weeks = await generateWeeksData( + from: dateRange.start, + to: dateRange.end, + dailyUsage: dailyUsage + ) + + return HeatmapDataset( + weeks: weeks, + monthLabels: generateMonthLabels(from: dateRange.start, to: dateRange.end), + maxCost: calculateMaxCost(from: dailyUsage), + dateRange: dateRange.start...dateRange.end + ) + } + + func generateWeeksData( + from startDate: Date, + to endDate: Date, + dailyUsage: [DailyUsage] + ) async -> [HeatmapWeek] { + let costLookup = buildCostLookup(from: dailyUsage) + let maxCost = calculateMaxCost(from: dailyUsage) + let weeksLayout = dateCalculator.generateWeeksLayout(from: startDate, to: endDate) + + return buildWeeks( + from: weeksLayout, + costLookup: costLookup, + maxCost: maxCost, + dateRange: startDate...endDate + ) + } + + func generateMonthLabels(from startDate: Date, to endDate: Date) -> [HeatmapMonth] { + dateCalculator + .generateMonthLabels(from: startDate, to: endDate) + .map(makeHeatmapMonth) + } +} + +// MARK: - Week Building + +extension HeatmapStore { + + func buildWeeks( + from weeksLayout: [[Date?]], + costLookup: [String: Double], + maxCost: Double, + dateRange: ClosedRange + ) -> [HeatmapWeek] { + weeksLayout.enumerated().map { weekIndex, weekDates in + HeatmapWeek( + weekNumber: weekIndex, + days: buildWeekDays( + from: weekDates, + weekIndex: weekIndex, + costLookup: costLookup, + maxCost: maxCost, + dateRange: dateRange + ) + ) + } + } + + func buildWeekDays( + from weekDates: [Date?], + weekIndex: Int, + costLookup: [String: Double], + maxCost: Double, + dateRange: ClosedRange + ) -> [HeatmapDay?] { + weekDates.enumerated().map { dayIndex, dayDate in + guard let date = dayDate, dateRange.contains(date) else { return nil } + return createHeatmapDay( + for: date, + dayIndex: dayIndex, + weekIndex: weekIndex, + costLookup: costLookup, + maxCost: maxCost + ) + } + } + + func createHeatmapDay( + for date: Date, + dayIndex: Int, + weekIndex: Int, + costLookup: [String: Double], + maxCost: Double + ) -> HeatmapDay { + let dateString = dateCalculator.formatDateAsID(date) + let calendarProps = dateCalculator.calendarProperties(for: date) + + return HeatmapDay( + date: date, + cost: costLookup[dateString] ?? DataGenerationConstants.defaultCost, + dayOfYear: calendarProps.dayOfYear, + weekOfYear: weekIndex, + dayOfWeek: dayIndex, + maxCost: maxCost + ) + } +} + +// MARK: - Validation + +extension HeatmapStore { + + func validateDailyUsage(_ dailyUsage: [DailyUsage]) throws -> [DailyUsage] { + let invalidDates = findInvalidDates(in: dailyUsage) + + guard invalidDates.isEmpty else { + throw HeatmapError.invalidDateRange(formatInvalidDatesMessage(invalidDates)) + } + + return dailyUsage + } + + func calculateValidDateRange() throws -> (start: Date, end: Date) { + let dateRange = dateCalculator.rollingDateRangeWithCompleteWeeks( + numberOfDays: DataGenerationConstants.rollingDateRangeDays + ) + let validationErrors = dateCalculator.validateDateRange( + startDate: dateRange.start, + endDate: dateRange.end + ) + + guard validationErrors.isEmpty else { + throw HeatmapError.invalidDateRange(validationErrors.joined(separator: ", ")) + } + + return dateRange + } +} + +// MARK: - Pure Functions + +extension HeatmapStore { + + func buildCostLookup(from dailyUsage: [DailyUsage]) -> [String: Double] { + Dictionary( + dailyUsage.map { ($0.date, $0.totalCost) }, + uniquingKeysWith: { first, _ in first } + ) + } + + func calculateMaxCost(from dailyUsage: [DailyUsage]) -> Double { + dailyUsage + .map(\.totalCost) + .max() ?? DataGenerationConstants.defaultMaxCost + } +} + +// MARK: - Private Helpers + +private extension HeatmapStore { + + func delayForTestability() async throws { + try await Task.sleep(nanoseconds: DataGenerationConstants.testabilityDelayNanoseconds) + } + + func processStatsIntoDataset(_ stats: UsageStats) async throws -> HeatmapDataset { + let validDailyUsage = try validateDailyUsage(stats.byDate) + let dateRange = try calculateValidDateRange() + return await buildDataset(from: validDailyUsage, dateRange: dateRange) + } + + func findInvalidDates(in dailyUsage: [DailyUsage]) -> [String] { + let dateFormatter = makeDateFormatter() + return DailyUsageValidator.findInvalidDates(in: dailyUsage, using: dateFormatter) + } + + func makeDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = DataGenerationConstants.dateFormat + formatter.timeZone = TimeZone.current + return formatter + } + + func formatInvalidDatesMessage(_ dates: [String]) -> String { + "Invalid date format(s): \(dates.joined(separator: ", "))" + } +} + +// MARK: - Model Factories + +private extension HeatmapStore { + + func makeHeatmapMonth(from info: HeatmapDateCalculator.MonthInfo) -> HeatmapMonth { + HeatmapMonth( + name: info.name, + weekSpan: info.weekSpan, + fullName: info.fullName, + monthNumber: info.monthNumber, + year: info.year + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift new file mode 100644 index 0000000..4bee3cd --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift @@ -0,0 +1,98 @@ +// +// HeatmapStore+SupportingTypes.swift +// +// Supporting types for HeatmapStore: errors, stats, and factory. +// + +import Foundation + +// MARK: - Heatmap Error + +/// Heatmap-specific error types +public enum HeatmapError: Error, LocalizedError { + case invalidDateRange(String) + case dataProcessingFailed(String) + case configurationInvalid(String) + case performanceThresholdExceeded + + public var errorDescription: String? { + switch self { + case .invalidDateRange(let message): + return "Invalid date range: \(message)" + case .dataProcessingFailed(let message): + return "Data processing failed: \(message)" + case .configurationInvalid(let message): + return "Configuration invalid: \(message)" + case .performanceThresholdExceeded: + return "Performance threshold exceeded - consider using a smaller date range" + } + } +} + +// MARK: - Summary Statistics + +/// Summary statistics for heatmap data +public struct SummaryStats { + public let totalCost: Double + public let daysWithUsage: Int + public let totalDays: Int + public let maxDailyCost: Double + public let averageDailyCost: Double + public let dateRange: ClosedRange + + /// Usage frequency as percentage + public var usageFrequency: Double { + guard totalDays > 0 else { return 0 } + return Double(daysWithUsage) / Double(totalDays) * 100 + } + + /// Formatted date range string + public var dateRangeString: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return "\(formatter.string(from: dateRange.lowerBound)) - \(formatter.string(from: dateRange.upperBound))" + } +} + +// MARK: - Performance Metrics + +/// Performance metrics for monitoring +struct PerformanceMetrics { + var datasetGenerationTime: Double = 0 + var hoverEventCount: Int = 0 + var averageHoverTime: Double = 0 + + var summary: String { + return """ + Dataset Generation: \(String(format: "%.3f", datasetGenerationTime))s + Hover Events: \(hoverEventCount) + Avg Hover Time: \(String(format: "%.4f", averageHoverTime))s + """ + } +} + +// MARK: - View Model Factory + +/// Factory for creating pre-configured view models +public struct HeatmapStoreFactory { + + /// Create view model optimized for performance + @MainActor + public static func performanceOptimized() -> HeatmapStore { + return HeatmapStore(configuration: .performanceOptimized) + } + + /// Create view model for compact displays + @MainActor + public static func compact() -> HeatmapStore { + return HeatmapStore(configuration: .compact) + } + + /// Create view model with custom configuration + /// - Parameter configuration: Custom configuration + /// - Returns: Configured view model + @MainActor + public static func custom(_ configuration: HeatmapConfiguration) -> HeatmapStore { + return HeatmapStore(configuration: configuration) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift new file mode 100644 index 0000000..23986e5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift @@ -0,0 +1,328 @@ +// +// HeatmapStore.swift +// Business logic and state management for heatmap visualization +// +// Split into extensions for focused responsibilities: +// - +DataGeneration: Dataset generation and processing +// - +SupportingTypes: Error types, summary stats, factory +// + +import SwiftUI +import Foundation +import Observation +import ClaudeUsageCore +import OSLog + +private let logger = Logger(subsystem: "com.claudecodeusage", category: "HeatmapStore") + +// MARK: - Constants + +private enum ViewModelConstants { + static let gridContentPadding: CGFloat = 4 + static let daysPerWeek = 7 +} + +private enum AccessibilityStrings { + static let datePrefix = "Usage on" + static let costPrefix = "Cost:" +} + +// MARK: - Heatmap View Model + +/// View model managing heatmap data, state, and business logic +@Observable +@MainActor +public final class HeatmapStore { + + // MARK: - Observable Properties + + /// Current heatmap dataset + public internal(set) var dataset: HeatmapDataset? + + /// Currently hovered day + public var hoveredDay: HeatmapDay? + + /// Tooltip position for hovered day + public var tooltipPosition: CGPoint = .zero + + /// Whether tooltip should flip to left side (near right edge) + public var tooltipShouldFlipLeft: Bool = false + + /// Loading state + public private(set) var isLoading: Bool = false + + /// Error state + public private(set) var error: HeatmapError? + + /// Configuration settings + public var configuration: HeatmapConfiguration { + didSet { + // Regenerate dataset when configuration changes + if let stats = currentStats, configuration != oldValue { + Task { + await generateDataset(from: stats) + } + } + } + } + + // MARK: - Internal Properties + + /// Current usage stats + var currentStats: UsageStats? + + /// Date calculator utility + let dateCalculator = HeatmapDateCalculator.shared + + /// Color manager for optimized color calculations + let colorManager = HeatmapColorManager.shared + + /// Performance metrics + var performanceMetrics = PerformanceMetrics() + + // MARK: - Initialization + + /// Initialize with configuration + /// - Parameter configuration: Heatmap configuration (defaults to standard) + public init(configuration: HeatmapConfiguration = .default) { + self.configuration = configuration + } + + // MARK: - Public Interface (High Level) + + /// Update heatmap with new usage statistics + /// - Parameter stats: Usage statistics to visualize + public func updateStats(_ stats: UsageStats) async { + isLoading = true + error = nil + currentStats = stats + + await generateDataset(from: stats) + + isLoading = false + } + + /// Handle hover at specific location + /// - Parameters: + /// - location: Cursor location in heatmap coordinate space + /// - gridBounds: Bounds of the heatmap grid + public func handleHover(at location: CGPoint, in gridBounds: CGRect) { + trackHoverPerformance { + updateHoveredDay(at: location, in: gridBounds) + } + } + + /// End hover interaction + public func endHover() { + hoveredDay = nil + } + + /// Get accessibility label for a specific day + public func accessibilityLabel(for day: HeatmapDay) -> String { + formatAccessibilityLabel(dateString: day.dateString, costString: day.costString) + } + + /// Get summary statistics for the current dataset + public var summaryStats: SummaryStats? { + dataset.map(buildSummaryStats) + } + + // MARK: - Hover Handling + + /// Update hovered day based on location + func updateHoveredDay(at location: CGPoint, in gridBounds: CGRect) { + guard let dataset else { + clearHoveredDay() + return + } + + let day = findDayAtLocation(location, in: dataset) + guard isNewHoveredDay(day) else { return } + + hoveredDay = day + updateTooltipIfNeeded(for: day, in: dataset) + } + + /// Find day at specific location in heatmap grid + func findDayAtLocation(_ location: CGPoint, in dataset: HeatmapDataset) -> HeatmapDay? { + let indices = calculateGridIndices(from: location) + guard isValidGridPosition(indices, in: dataset) else { return nil } + return dataset.weeks[indices.week].days[indices.day] + } + + // MARK: - Performance Tracking + + /// Track hover event performance + func trackHoverPerformance(_ operation: () -> Void) { + performanceMetrics.hoverEventCount += 1 + let startTime = CFAbsoluteTimeGetCurrent() + + operation() + + let duration = CFAbsoluteTimeGetCurrent() - startTime + performanceMetrics.averageHoverTime = (performanceMetrics.averageHoverTime + duration) / 2 + } + + /// Record dataset generation time + func recordDatasetGenerationTime(since startTime: CFAbsoluteTime) { + let duration = CFAbsoluteTimeGetCurrent() - startTime + performanceMetrics.datasetGenerationTime = duration + logger.debug("Generated heatmap dataset in \(String(format: "%.3f", duration))s") + } + + /// Handle dataset generation error + func handleDatasetError(_ error: Error) { + self.error = error as? HeatmapError ?? .dataProcessingFailed(error.localizedDescription) + self.dataset = nil + } +} + +// MARK: - Public State Properties + +public extension HeatmapStore { + + /// Whether the heatmap has data to display + var hasData: Bool { + dataset?.weeks.isEmpty == false + } + + /// Whether the heatmap is in an error state + var hasError: Bool { + error != nil + } + + /// Whether the heatmap is currently interactive (not loading, has data) + var isInteractive: Bool { + !isLoading && hasData && !hasError + } +} + +// MARK: - Hover State Helpers + +private extension HeatmapStore { + + func clearHoveredDay() { + hoveredDay = nil + } + + func isNewHoveredDay(_ day: HeatmapDay?) -> Bool { + hoveredDay?.id != day?.id + } + + func updateTooltipIfNeeded(for day: HeatmapDay?, in dataset: HeatmapDataset) { + guard let day else { return } + tooltipPosition = calculateTooltipPosition(for: day) + tooltipShouldFlipLeft = shouldFlipTooltipLeft(for: day, totalWeeks: dataset.weeks.count) + } + + func calculateTooltipPosition(for day: HeatmapDay) -> CGPoint { + TooltipPositionCalculator.position( + for: day, + cellSize: configuration.cellSize, + squareSize: configuration.squareSize + ) + } + + func shouldFlipTooltipLeft(for day: HeatmapDay, totalWeeks: Int) -> Bool { + TooltipPositionCalculator.shouldFlipLeft(day: day, totalWeeks: totalWeeks) + } +} + +// MARK: - Grid Index Calculations + +private extension HeatmapStore { + + typealias GridIndices = (week: Int, day: Int) + + func calculateGridIndices(from location: CGPoint) -> GridIndices { + let cellSize = configuration.cellSize + let weekIndex = Int((location.x - ViewModelConstants.gridContentPadding) / cellSize) + let dayIndex = Int(location.y / cellSize) + return (week: weekIndex, day: dayIndex) + } + + func isValidGridPosition(_ indices: GridIndices, in dataset: HeatmapDataset) -> Bool { + isValidWeekIndex(indices.week, in: dataset) && isValidDayIndex(indices.day) + } + + func isValidWeekIndex(_ index: Int, in dataset: HeatmapDataset) -> Bool { + index >= 0 && index < dataset.weeks.count + } + + func isValidDayIndex(_ index: Int) -> Bool { + index >= 0 && index < ViewModelConstants.daysPerWeek + } +} + +// MARK: - Pure Transformation Functions + +private extension HeatmapStore { + + func buildSummaryStats(from dataset: HeatmapDataset) -> SummaryStats { + SummaryStats( + totalCost: dataset.totalCost, + daysWithUsage: dataset.daysWithUsage, + totalDays: dataset.allDays.count, + maxDailyCost: dataset.maxCost, + averageDailyCost: calculateAverageDailyCost(total: dataset.totalCost, days: dataset.allDays.count), + dateRange: dataset.dateRange + ) + } + + func calculateAverageDailyCost(total: Double, days: Int) -> Double { + total / Double(max(1, days)) + } + + func formatAccessibilityLabel(dateString: String, costString: String) -> String { + "\(AccessibilityStrings.datePrefix) \(dateString), \(AccessibilityStrings.costPrefix) \(costString)" + } +} + +// MARK: - Supporting Types + +/// Tooltip position calculation (pure functions) +enum TooltipPositionCalculator { + /// Fixed Y position for tooltip (in header area) + private static let fixedY: CGFloat = 30 + + /// Calculate tooltip position for a hovered day + /// - Parameters: + /// - day: The day being hovered + /// - cellSize: Size of each cell in the grid + /// - squareSize: Size of the day square (unused, kept for API compatibility) + /// - gridContentPadding: Horizontal padding applied to grid content + /// - Returns: Position for the tooltip (X follows column, Y fixed at header) + static func position( + for day: HeatmapDay, + cellSize: CGFloat, + squareSize: CGFloat, + gridContentPadding: CGFloat = 4 + ) -> CGPoint { + let squareCenterX = CGFloat(day.weekOfYear) * cellSize + (cellSize / 2) + gridContentPadding + + return CGPoint( + x: squareCenterX, + y: fixedY + ) + } + + /// Determine if tooltip should appear on the left side of the cell + static func shouldFlipLeft(day: HeatmapDay, totalWeeks: Int) -> Bool { + let weeksFromEnd = totalWeeks - day.weekOfYear + return weeksFromEnd < 8 + } +} + +/// Date validation (pure functions) +enum DailyUsageValidator { + /// Validate daily usage dates and return invalid date strings + /// - Parameters: + /// - dailyUsage: Array of daily usage records + /// - dateFormatter: Formatter to validate dates + /// - Returns: Array of invalid date strings + static func findInvalidDates(in dailyUsage: [DailyUsage], using dateFormatter: DateFormatter) -> [String] { + dailyUsage + .map(\.date) + .filter { dateFormatter.date(from: $0) == nil } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift new file mode 100644 index 0000000..c1500fe --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift @@ -0,0 +1,39 @@ +// +// HeatmapTooltip+Factory.swift +// Convenience factory methods for common tooltip configurations +// + +import SwiftUI + +// MARK: - Convenience Extensions + +public extension HeatmapTooltip { + + /// Create a quick tooltip with minimal styling + /// - Parameters: + /// - day: Day data + /// - position: Position on screen + /// - Returns: Minimal tooltip + static func quick(day: HeatmapDay, position: CGPoint) -> HeatmapTooltip { + HeatmapTooltip( + day: day, + position: position, + style: .minimal, + configuration: .minimal + ) + } + + /// Create a rich tooltip with detailed information + /// - Parameters: + /// - day: Day data + /// - position: Position on screen + /// - Returns: Detailed tooltip + static func rich(day: HeatmapDay, position: CGPoint) -> HeatmapTooltip { + HeatmapTooltip( + day: day, + position: position, + style: .detailed, + configuration: .enhanced + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift new file mode 100644 index 0000000..25c8b15 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift @@ -0,0 +1,373 @@ +// +// HeatmapTooltip.swift +// Tooltip component for heatmap hover interactions +// + +import SwiftUI + +// MARK: - Heatmap Tooltip + +/// Customizable tooltip for displaying heatmap day information +public struct HeatmapTooltip: View { + + // MARK: - Configuration + + /// Tooltip content style + public enum TooltipStyle { + case minimal // Cost and date only + case standard // Cost, date, and basic info + case detailed // Cost, date, and additional statistics + case custom // Fully customizable content + } + + /// Tooltip positioning strategy + public enum PositioningStrategy { + case automatic // Smart positioning based on screen bounds + case fixed // Fixed offset from cursor + case adaptive // Adapts to content size + } + + // MARK: - Properties + + /// Day data to display + let day: HeatmapDay + + /// Tooltip position on screen + let position: CGPoint + + /// Display style + let style: TooltipStyle + + /// Positioning strategy + let positioning: PositioningStrategy + + /// Custom content builder (for .custom style) + let customContent: ((HeatmapDay) -> AnyView)? + + /// Screen bounds for smart positioning + let screenBounds: CGRect + + /// Whether to flip tooltip to the left side + let shouldFlipLeft: Bool + + /// Configuration settings + private let configuration: TooltipConfiguration + + // MARK: - Initialization + + /// Initialize tooltip with day data + /// - Parameters: + /// - day: Day to display information for + /// - position: Position on screen + /// - style: Display style (default: standard) + /// - positioning: Positioning strategy (default: automatic) + /// - screenBounds: Screen bounds for positioning + /// - shouldFlipLeft: Whether to flip tooltip to the left side + /// - configuration: Tooltip configuration + /// - customContent: Custom content builder (for .custom style) + public init( + day: HeatmapDay, + position: CGPoint, + style: TooltipStyle = .standard, + positioning: PositioningStrategy = .automatic, + screenBounds: CGRect = NSScreen.main?.frame ?? .zero, + shouldFlipLeft: Bool = false, + configuration: TooltipConfiguration = .default, + customContent: ((HeatmapDay) -> AnyView)? = nil + ) { + self.day = day + self.position = position + self.style = style + self.positioning = positioning + self.screenBounds = screenBounds + self.shouldFlipLeft = shouldFlipLeft + self.configuration = configuration + self.customContent = customContent + } + + // MARK: - Body + + public var body: some View { + tooltipContent + .background(tooltipBackground) + .cornerRadius(configuration.cornerRadius) + .shadow( + color: configuration.shadowColor, + radius: configuration.shadowRadius, + x: configuration.shadowOffset.width, + y: configuration.shadowOffset.height + ) + .offset(calculatedOffset) + .opacity(configuration.opacity) + .scaleEffect(configuration.scale) + .animation(configuration.animation, value: day.id) + } + + // MARK: - Tooltip Content (Mid Level) + + @ViewBuilder + private var tooltipContent: some View { + switch style { + case .minimal: + minimalContent + case .standard: + standardContent + case .detailed: + detailedContent + case .custom: + customContentView + } + } + + // MARK: - Content Variants (Mid Level) + + @ViewBuilder + private var minimalContent: some View { + VStack(alignment: .leading, spacing: 2) { + Text(day.costString) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.primary) + + Text(day.dateString) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + @ViewBuilder + private var standardContent: some View { + VStack(alignment: .leading, spacing: 4) { + standardPrimaryRow + standardDateLabel + standardUsageStatus + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + @ViewBuilder + private var detailedContent: some View { + VStack(alignment: .leading, spacing: 6) { + detailedHeader + detailedDivider + detailedStatistics + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var customContentView: some View { + if let customContent = customContent { + customContent(day) + } else { + standardContent + } + } + + // MARK: - Standard Content Components (Low Level) + + @ViewBuilder + private var standardPrimaryRow: some View { + HStack(spacing: 8) { + Text(day.costString) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.primary) + + if day.isToday { + standardTodayBadge + } + } + } + + private var standardTodayBadge: some View { + Text("Today") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue) + .cornerRadius(4) + } + + private var standardDateLabel: some View { + Text(day.dateString) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + + @ViewBuilder + private var standardUsageStatus: some View { + if day.isEmpty { + Text("No usage") + .font(.system(size: 9)) + .foregroundColor(.secondary) + } else { + Text("Usage recorded") + .font(.system(size: 9)) + .foregroundColor(.green) + } + } + + // MARK: - Detailed Content Components (Low Level) + + @ViewBuilder + private var detailedHeader: some View { + HStack { + detailedCostAndDate + Spacer() + if day.isToday { + detailedTodayBadge + } + } + } + + @ViewBuilder + private var detailedCostAndDate: some View { + VStack(alignment: .leading, spacing: 2) { + Text(day.costString) + .font(.system(size: 13, weight: .bold)) + .foregroundColor(.primary) + + Text(day.dateString) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private var detailedTodayBadge: some View { + VStack(spacing: 2) { + Image(systemName: "calendar.badge.clock") + .font(.system(size: 12)) + .foregroundColor(.blue) + Text("Today") + .font(.system(size: 8, weight: .medium)) + .foregroundColor(.blue) + } + } + + private var detailedDivider: some View { + Rectangle() + .fill(Color.gray.opacity(0.3)) + .frame(height: 0.5) + } + + @ViewBuilder + private var detailedStatistics: some View { + VStack(alignment: .leading, spacing: 3) { + if !day.isEmpty { + activityLevelRow + intensityRow + } else { + noActivityRow + } + dayOfYearLabel + } + } + + @ViewBuilder + private var activityLevelRow: some View { + HStack { + Text("Activity Level:") + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Spacer() + + Text(activityLevel.text) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(activityLevel.color) + } + } + + @ViewBuilder + private var intensityRow: some View { + HStack { + Text("Intensity:") + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Spacer() + + Text("\(Int(day.intensity * 100))%") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.primary) + } + } + + @ViewBuilder + private var noActivityRow: some View { + HStack { + Image(systemName: "moon.zzz") + .font(.system(size: 10)) + .foregroundColor(.gray) + + Text("No activity recorded") + .font(.system(size: 9)) + .foregroundColor(.gray) + } + } + + private var dayOfYearLabel: some View { + Text("Day \(day.dayOfYear) of year") + .font(.system(size: 8)) + .foregroundColor(.secondary) + } + + // MARK: - Background + + @ViewBuilder + private var tooltipBackground: some View { + Rectangle() + .fill(configuration.backgroundMaterial) + } + + // MARK: - Computed Properties + + private var calculatedOffset: CGSize { + TooltipPositioning.offset( + for: positioning, + position: position, + screenBounds: screenBounds, + style: style, + shouldFlipLeft: shouldFlipLeft + ) + } + + private var activityLevel: ActivityLevel { + ActivityLevel(intensity: day.intensity) + } +} + +// MARK: - Preview + +#if DEBUG +struct HeatmapTooltip_Previews: PreviewProvider { + static var previews: some View { + let sampleDay = HeatmapDay( + date: Date(), + cost: 12.45, + dayOfYear: 285, + weekOfYear: 41, + dayOfWeek: 3, + maxCost: 25.0 + ) + + VStack(spacing: 30) { + // Minimal tooltip + HeatmapTooltip.quick(day: sampleDay, position: .zero) + + // Standard tooltip + HeatmapTooltip(day: sampleDay, position: .zero, style: .standard) + + // Detailed tooltip + HeatmapTooltip.rich(day: sampleDay, position: .zero) + } + .padding(50) + .background(Color(.windowBackgroundColor)) + } +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift new file mode 100644 index 0000000..96018d1 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift @@ -0,0 +1,66 @@ +// +// HeatmapTooltipBuilder.swift +// Builder pattern for creating customized tooltips +// + +import SwiftUI + +// MARK: - Tooltip Builder + +/// Builder for creating customized tooltips +public struct HeatmapTooltipBuilder: @unchecked Sendable { + private var day: HeatmapDay + private var position: CGPoint + private var style: HeatmapTooltip.TooltipStyle = .standard + private var positioning: HeatmapTooltip.PositioningStrategy = .automatic + private var screenBounds: CGRect = NSScreen.main?.frame ?? .zero + private var configuration: TooltipConfiguration = .default + private var customContent: ((HeatmapDay) -> AnyView)? + + public init(day: HeatmapDay, position: CGPoint) { + self.day = day + self.position = position + } + + public func style(_ tooltipStyle: HeatmapTooltip.TooltipStyle) -> Self { + var builder = self + builder.style = tooltipStyle + return builder + } + + public func positioning(_ strategy: HeatmapTooltip.PositioningStrategy) -> Self { + var builder = self + builder.positioning = strategy + return builder + } + + public func screenBounds(_ bounds: CGRect) -> Self { + var builder = self + builder.screenBounds = bounds + return builder + } + + public func configuration(_ config: TooltipConfiguration) -> Self { + var builder = self + builder.configuration = config + return builder + } + + public func customContent(_ content: @escaping (HeatmapDay) -> AnyView) -> Self { + var builder = self + builder.customContent = content + return builder + } + + public func build() -> HeatmapTooltip { + HeatmapTooltip( + day: day, + position: position, + style: style, + positioning: positioning, + screenBounds: screenBounds, + configuration: configuration, + customContent: customContent + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift new file mode 100644 index 0000000..32bd44e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift @@ -0,0 +1,68 @@ +// +// TooltipConfiguration.swift +// Configuration struct for tooltip appearance and behavior +// + +import SwiftUI + +// MARK: - Tooltip Configuration + +/// Configuration for tooltip appearance and behavior +public struct TooltipConfiguration: @unchecked Sendable { + + /// Background material + public let backgroundMaterial: Material + + /// Corner radius + public let cornerRadius: CGFloat + + /// Shadow properties + public let shadowColor: Color + public let shadowRadius: CGFloat + public let shadowOffset: CGSize + + /// Opacity + public let opacity: Double + + /// Scale + public let scale: CGFloat + + /// Animation + public let animation: Animation? + + /// Default configuration + public static let `default` = TooltipConfiguration( + backgroundMaterial: .regularMaterial, + cornerRadius: 8, + shadowColor: .black.opacity(0.15), + shadowRadius: 6, + shadowOffset: CGSize(width: 0, height: 2), + opacity: 1.0, + scale: 1.0, + animation: .easeInOut(duration: 0.2) + ) + + /// Minimal configuration without shadows or animations + public static let minimal = TooltipConfiguration( + backgroundMaterial: .thinMaterial, + cornerRadius: 4, + shadowColor: .clear, + shadowRadius: 0, + shadowOffset: .zero, + opacity: 0.95, + scale: 1.0, + animation: nil + ) + + /// Enhanced configuration with prominent styling + public static let enhanced = TooltipConfiguration( + backgroundMaterial: .thickMaterial, + cornerRadius: 12, + shadowColor: .black.opacity(0.25), + shadowRadius: 10, + shadowOffset: CGSize(width: 0, height: 4), + opacity: 1.0, + scale: 1.05, + animation: .spring(response: 0.4, dampingFraction: 0.8) + ) +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift new file mode 100644 index 0000000..312963b --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift @@ -0,0 +1,61 @@ +// +// TooltipPositioning.swift +// Pure functions for tooltip positioning calculations +// + +import SwiftUI + +// MARK: - Tooltip Positioning (Pure Functions) + +enum TooltipPositioning { + + static func offset( + for strategy: HeatmapTooltip.PositioningStrategy, + position: CGPoint, + screenBounds: CGRect, + style: HeatmapTooltip.TooltipStyle, + shouldFlipLeft: Bool = false + ) -> CGSize { + switch strategy { + case .automatic: + smartOffset(position: position, screenBounds: screenBounds, style: style, shouldFlipLeft: shouldFlipLeft) + case .fixed: + CGSize(width: shouldFlipLeft ? -150 : 10, height: -30) + case .adaptive: + adaptiveOffset(style: style, shouldFlipLeft: shouldFlipLeft) + } + } + + private static func smartOffset( + position: CGPoint, + screenBounds: CGRect, + style: HeatmapTooltip.TooltipStyle, + shouldFlipLeft: Bool + ) -> CGSize { + let size = estimatedSize(for: style) + let gap: CGFloat = 8 + + // Position tooltip edge near cell center + // .position() places tooltip CENTER, so offset by half-width to align edge + let adjustedX: CGFloat = shouldFlipLeft + ? -(size.width / 2) - gap // Right edge near cell + : (size.width / 2) + gap // Left edge near cell + + return CGSize(width: adjustedX, height: 0) + } + + private static func adaptiveOffset(style: HeatmapTooltip.TooltipStyle, shouldFlipLeft: Bool) -> CGSize { + let size = estimatedSize(for: style) + let xOffset = shouldFlipLeft ? -size.width - 10 : -size.width / 2 + return CGSize(width: xOffset, height: -size.height - 15) + } + + static func estimatedSize(for style: HeatmapTooltip.TooltipStyle) -> CGSize { + switch style { + case .minimal: CGSize(width: 100, height: 35) + case .standard: CGSize(width: 140, height: 50) + case .detailed: CGSize(width: 180, height: 85) + case .custom: CGSize(width: 150, height: 60) + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift new file mode 100644 index 0000000..b474f51 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift @@ -0,0 +1,155 @@ +// +// YearlyCostHeatmap+Factories.swift +// +// Static factory methods and legacy compatibility for YearlyCostHeatmap. +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Legacy Compatibility + +/// Legacy extension providing the original interface for backward compatibility +public extension YearlyCostHeatmap { + + /// Legacy initializer matching the original component interface + /// - Parameters: + /// - stats: Usage statistics + /// - year: Year (ignored, rolling year used instead) + /// - Returns: Configured heatmap with default settings + @available(*, deprecated, message: "Use init(stats:year:configuration:) with explicit configuration instead") + static func legacy(stats: UsageStats, year: Int) -> YearlyCostHeatmap { + return YearlyCostHeatmap( + stats: stats, + year: year, + configuration: .default + ) + } + + /// Performance-optimized version for large datasets + /// - Parameters: + /// - stats: Usage statistics + /// - year: Year (ignored) + /// - Returns: Performance-optimized heatmap + static func performanceOptimized(stats: UsageStats, year: Int) -> YearlyCostHeatmap { + return YearlyCostHeatmap( + stats: stats, + year: year, + configuration: .performanceOptimized + ) + } + + /// Compact version for limited space + /// - Parameters: + /// - stats: Usage statistics + /// - year: Year (ignored) + /// - Returns: Compact heatmap + static func compact(stats: UsageStats, year: Int) -> YearlyCostHeatmap { + return YearlyCostHeatmap( + stats: stats, + year: year, + configuration: .compact + ) + } +} + +// MARK: - Custom Configurations + +public extension YearlyCostHeatmap { + + /// Create heatmap with custom color theme + /// - Parameters: + /// - stats: Usage statistics + /// - year: Year (ignored) + /// - colorTheme: Custom color theme + /// - Returns: Heatmap with custom colors + static func withColorTheme( + stats: UsageStats, + year: Int, + colorTheme: HeatmapColorTheme + ) -> YearlyCostHeatmap { + let config = HeatmapConfiguration.default + // Note: This would require modifying HeatmapConfiguration to be mutable + // For now, we'll use the default configuration + return YearlyCostHeatmap(stats: stats, year: year, configuration: config) + } + + /// Create heatmap with accessibility optimizations + /// - Parameters: + /// - stats: Usage statistics + /// - year: Year (ignored) + /// - Returns: Accessibility-optimized heatmap + static func accessible(stats: UsageStats, year: Int) -> YearlyCostHeatmap { + // Create configuration optimized for accessibility + let config = HeatmapConfiguration( + squareSize: 14, // Larger squares + spacing: 3, // More spacing + cornerRadius: 2, + padding: EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20), + colorScheme: .github, // High contrast theme would be better + showMonthLabels: true, + showDayLabels: true, + showLegend: true, + monthLabelFont: .body, // Larger font + dayLabelFont: .subheadline, + legendFont: .body, + enableTooltips: true, + tooltipDelay: 0.1, + highlightToday: true, + todayHighlightColor: .blue, + todayHighlightWidth: 3, // Thicker border + animationDuration: 0.0, // No animations for accessibility + animateColorTransitions: false, + scaleOnHover: false, + hoverScale: 1.0 + ) + + return YearlyCostHeatmap(stats: stats, year: year, configuration: config) + } +} + +// MARK: - Migration Guide + +/* + MIGRATION GUIDE: Upgrading from Legacy YearlyCostHeatmap + + The YearlyCostHeatmap component has been completely refactored with clean architecture. + While backward compatibility is maintained, consider migrating to the new API: + + OLD (still works): + ```swift + YearlyCostHeatmap(stats: stats, year: 2024) + ``` + + NEW (recommended): + ```swift + YearlyCostHeatmap( + stats: stats, + year: 2024, + configuration: .default // or .performanceOptimized, .compact + ) + ``` + + PERFORMANCE OPTIMIZED: + ```swift + YearlyCostHeatmap.performanceOptimized(stats: stats, year: 2024) + ``` + + COMPACT VERSION: + ```swift + YearlyCostHeatmap.compact(stats: stats, year: 2024) + ``` + + ACCESSIBILITY OPTIMIZED: + ```swift + YearlyCostHeatmap.accessible(stats: stats, year: 2024) + ``` + + BENEFITS OF MIGRATION: + - Better performance with optimized configurations + - Improved accessibility support + - More customization options + - Better error handling and loading states + - Type-safe configuration + - Easier testing with separated concerns + */ diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift new file mode 100644 index 0000000..9710d8e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift @@ -0,0 +1,105 @@ +// +// YearlyCostHeatmap+Preview.swift +// +// Preview provider for YearlyCostHeatmap development. +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Preview + +#if DEBUG +struct YearlyCostHeatmap_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(spacing: 30) { + // Default configuration + YearlyCostHeatmap(stats: sampleStats, year: 2024) + + // Performance optimized + YearlyCostHeatmap.performanceOptimized(stats: sampleStats, year: 2024) + + // Compact version + YearlyCostHeatmap.compact(stats: sampleStats, year: 2024) + + // Accessibility optimized + YearlyCostHeatmap.accessible(stats: sampleStats, year: 2024) + } + .padding() + } + .background(Color(.windowBackgroundColor)) + } + + static var sampleStats: UsageStats { + let dailyUsage = generateSampleDailyUsage() + return UsageStats( + totalCost: dailyUsage.reduce(0) { $0 + $1.totalCost }, + tokens: TokenCounts( + input: 250000, + output: 150000, + cacheCreation: 0, + cacheRead: 0 + ), + sessionCount: 150, + byModel: [], + byDate: dailyUsage, + byProject: [] + ) + } + + private static func generateSampleDailyUsage() -> [DailyUsage] { + let calendar = Calendar.current + let today = Date() + let dateFormatter = makeDateFormatter() + + return (0..<365).map { dayOffset in + makeDailyUsage( + dayOffset: dayOffset, + today: today, + calendar: calendar, + dateFormatter: dateFormatter + ) + } + } + + private static func makeDateFormatter() -> DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + } + + private static func makeDailyUsage( + dayOffset: Int, + today: Date, + calendar: Calendar, + dateFormatter: DateFormatter + ) -> DailyUsage { + let date = calendar.date(byAdding: .day, value: -dayOffset, to: today)! + let cost = calculateSampleCost(for: date, dayOffset: dayOffset, calendar: calendar) + return DailyUsage( + date: dateFormatter.string(from: date), + totalCost: cost, + totalTokens: Int(cost * 1000), + modelsUsed: ["claude-sonnet-4"] + ) + } + + private static func calculateSampleCost( + for date: Date, + dayOffset: Int, + calendar: Calendar + ) -> Double { + let hasNoRecentActivity = dayOffset >= 300 + guard !hasNoRecentActivity else { return 0 } + + let weekday = calendar.component(.weekday, from: date) + let isWeekend = weekday == 1 || weekday == 7 + let baseUsage = isWeekend ? 0.3 : 1.0 + let randomFactor = Double.random(in: 0.2...1.8) + let rawCost = baseUsage * randomFactor * 3.0 + let simulateNoUsageDay = rawCost > 2.8 + return simulateNoUsageDay ? 0 : rawCost + } +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift new file mode 100644 index 0000000..fb92e0a --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift @@ -0,0 +1,292 @@ +// +// YearlyCostHeatmap.swift +// Refactored yearly cost heatmap with clean architecture +// +// Modern SwiftUI heatmap component with MVVM architecture, +// comprehensive error handling, and performance optimizations. +// +// Split into extensions for focused responsibilities: +// - +Factories: Static factory methods and legacy compatibility +// - +Preview: Preview provider for development +// + +import SwiftUI +import ClaudeUsageCore +import Foundation + +// MARK: - Yearly Cost Heatmap + +/// GitHub-style contribution graph for daily cost visualization +/// +/// This component has been completely refactored to follow clean architecture principles: +/// - MVVM architecture with dedicated ViewModel +/// - Separated data models and business logic +/// - Reusable subcomponents (Grid, Legend, Tooltip) +/// - Comprehensive error handling and validation +/// - Performance optimizations with caching +/// - Accessibility support +/// - Backward compatibility maintained +public struct YearlyCostHeatmap: View { + + // MARK: - Properties + + /// Usage statistics to visualize + let stats: UsageStats + + /// Year parameter (kept for backward compatibility, now ignored in favor of rolling year) + let year: Int + + /// Configuration for heatmap appearance and behavior + let configuration: HeatmapConfiguration + + /// View model managing data and state + @State private var viewModel: HeatmapStore + + /// Screen bounds for tooltip positioning + @State private var screenBounds: CGRect = NSScreen.main?.frame ?? .zero + + // MARK: - Initialization + + /// Initialize with usage statistics and optional configuration + /// - Parameters: + /// - stats: Usage statistics to display + /// - year: Year (kept for backward compatibility, ignored) + /// - configuration: Heatmap configuration (defaults to standard) + public init( + stats: UsageStats, + year: Int, + configuration: HeatmapConfiguration = .default + ) { + self.stats = stats + self.year = year + self.configuration = configuration + + // Initialize view model with configuration + self._viewModel = State(wrappedValue: HeatmapStore(configuration: configuration)) + } + + // MARK: - Body + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + headerSection + contentSection + legendSectionIfVisible + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(configuration.padding) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + .overlay(tooltipOverlay, alignment: .topLeading) + .task { await viewModel.updateStats(stats) } + .onAppear { screenBounds = NSScreen.main?.frame ?? .zero } + } + + private var shouldShowLegend: Bool { + configuration.showLegend && viewModel.hasData + } + + @ViewBuilder + private var legendSectionIfVisible: some View { + if shouldShowLegend { + legendSection + } + } + + // MARK: - Header Section + + @ViewBuilder + private var headerSection: some View { + HStack { + titleAndSummary + Spacer() + costSummary + } + } + + private var titleAndSummary: some View { + VStack(alignment: .leading, spacing: 4) { + headerTitle + summarySubtitle + } + } + + private var headerTitle: some View { + HStack { + Image(systemName: "calendar.badge.plus") + .foregroundColor(.green) + Text("Daily Cost Activity") + .font(.headline) + .fontWeight(.semibold) + } + } + + @ViewBuilder + private var summarySubtitle: some View { + if let summary = viewModel.summaryStats { + Text("\(summary.daysWithUsage) days of usage in last 365 days") + .font(.caption) + .foregroundColor(.secondary) + } else if viewModel.isLoading { + Text("Loading usage data...") + .font(.caption) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private var costSummary: some View { + VStack(alignment: .trailing, spacing: 4) { + if let summary = viewModel.summaryStats { + Text(summary.totalCost.asCurrency) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text("Last 365 days") + .font(.caption) + .foregroundColor(.secondary) + } else if viewModel.isLoading { + ProgressView() + .scaleEffect(0.8) + } + } + } + + // MARK: - Content Section + + @ViewBuilder + private var contentSection: some View { + if viewModel.isLoading { + loadingView + } else if let error = viewModel.error { + errorView(error) + } else if let dataset = viewModel.dataset { + heatmapContent(dataset) + } else { + emptyStateView + } + } + + // MARK: - Loading View + + @ViewBuilder + private var loadingView: some View { + VStack(spacing: 12) { + ProgressView() + .scaleEffect(1.2) + + Text("Generating heatmap...") + .font(.subheadline) + .foregroundColor(.secondary) + } + .frame(height: 120) + .frame(maxWidth: .infinity) + } + + // MARK: - Error View + + @ViewBuilder + private func errorView(_ error: HeatmapError) -> some View { + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 24)) + .foregroundColor(.orange) + + Text("Unable to Display Heatmap") + .font(.headline) + .foregroundColor(.primary) + + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + Button("Retry") { + Task { + await viewModel.updateStats(stats) + } + } + .buttonStyle(.borderedProminent) + } + .frame(height: 120) + .frame(maxWidth: .infinity) + .padding() + } + + // MARK: - Empty State View + + @ViewBuilder + private var emptyStateView: some View { + VStack(spacing: 12) { + Image(systemName: "calendar") + .font(.system(size: 24)) + .foregroundColor(.gray) + + Text("No Usage Data") + .font(.headline) + .foregroundColor(.primary) + + Text("Heatmap will appear once you have usage data.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(height: 120) + .frame(maxWidth: .infinity) + } + + // MARK: - Heatmap Content + + @ViewBuilder + private func heatmapContent(_ dataset: HeatmapDataset) -> some View { + let gridLayout = HeatmapGridLayout(configuration: configuration, dataset: dataset) + + HeatmapGrid( + dataset: dataset, + configuration: configuration, + hoveredDay: viewModel.hoveredDay, + onHover: { location in + viewModel.handleHover(at: location, in: .zero) + }, + onEndHover: { + viewModel.endHover() + } + ) + .frame(height: gridLayout.totalSize.height) + .accessibilityLabel("Heatmap showing daily cost activity over the last 365 days") + .accessibilityAddTraits(.allowsDirectInteraction) + } + + // MARK: - Legend Section + + @ViewBuilder + private var legendSection: some View { + if let dataset = viewModel.dataset { + HeatmapLegend( + colorTheme: configuration.colorScheme, + maxCost: dataset.maxCost, + style: .horizontal, + font: configuration.legendFont + ) + } + } + + // MARK: - Tooltip Overlay + + @ViewBuilder + private var tooltipOverlay: some View { + if let hoveredDay = viewModel.hoveredDay, + configuration.enableTooltips { + HeatmapTooltip( + day: hoveredDay, + position: viewModel.tooltipPosition, + style: .standard, + screenBounds: screenBounds, + shouldFlipLeft: viewModel.tooltipShouldFlipLeft + ) + .position(viewModel.tooltipPosition) + .allowsHitTesting(false) + .animation(.easeInOut(duration: 0.1), value: viewModel.tooltipPosition) + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift new file mode 100644 index 0000000..f4e0417 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift @@ -0,0 +1,43 @@ +// +// EmptyStateView.swift +// Reusable empty state component +// + +import SwiftUI + +struct EmptyStateView: View { + let icon: String + let title: String + let message: String + + var body: some View { + VStack(spacing: 20) { + iconView + titleView + messageView + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + } + + private var iconView: some View { + Image(systemName: icon) + .font(.system(size: 60)) + .foregroundColor(.secondary) + } + + private var titleView: some View { + Text(title) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + + private var messageView: some View { + Text(message) + .font(.body) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift new file mode 100644 index 0000000..9016a9c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift @@ -0,0 +1,303 @@ +// +// DailyUsageView.swift +// Daily usage breakdown view +// + +import SwiftUI +import ClaudeUsageCore + +struct DailyUsageView: View { + @Environment(UsageStore.self) private var store + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + DailyUsageHeader() + DailyUsageContent(state: ContentState.from(store: store)) + } + .padding() + } + .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Content State + +@MainActor +private enum ContentState { + case loading + case empty + case loaded([DailyUsage]) + case error + + static func from(store: UsageStore) -> ContentState { + if store.isLoading { return .loading } + guard let stats = store.stats else { return .error } + return stats.byDate.isEmpty ? .empty : .loaded(stats.byDate) + } +} + +// MARK: - Content Router + +private struct DailyUsageContent: View { + let state: ContentState + + var body: some View { + switch state { + case .loading: + LoadingView(message: "Loading daily usage...") + case .empty: + EmptyStateView( + icon: "calendar", + title: "No Usage Data", + message: "Daily usage statistics will appear here once you start using Claude Code.\nData is collected from ~/.claude/projects/" + ) + case .loaded(let dates): + DailyUsageList(dates: dates) + case .error: + EmptyStateView( + icon: "calendar", + title: "No Data Available", + message: "Unable to load daily usage data." + ) + } + } +} + +// MARK: - Header + +private struct DailyUsageHeader: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + titleView + subtitleView + } + } + + private var titleView: some View { + Text("Daily Usage") + .font(.largeTitle) + .fontWeight(.bold) + } + + private var subtitleView: some View { + Text("Day-by-day breakdown of your usage") + .font(.subheadline) + .foregroundColor(.secondary) + } +} + +// MARK: - Loading View + +private struct LoadingView: View { + let message: String + + var body: some View { + ProgressView(message) + .frame(maxWidth: .infinity) + .padding(.top, 50) + } +} + +// MARK: - Grid + +private struct DailyUsageList: View { + let dates: [DailyUsage] + + private enum Layout { + static let minColumnWidth: CGFloat = 300 + static let spacing: CGFloat = 12 + } + + private var globalMaxHourlyCost: Double { + dates.flatMap(\.hourlyCosts).max() ?? 1.0 + } + + private var gridItems: [GridItem] { + [GridItem(.adaptive(minimum: Layout.minColumnWidth), spacing: Layout.spacing)] + } + + var body: some View { + LazyVGrid(columns: gridItems, spacing: Layout.spacing) { + ForEach(dates.reversed()) { daily in + DailyCard(daily: daily, maxHourlyCost: globalMaxHourlyCost) + } + } + } +} + +// MARK: - Daily Card + +struct DailyCard: View { + let daily: DailyUsage + var maxHourlyCost: Double? = nil // Shared scale for comparing charts + + private var dateInfo: DateInfo { DateInfo.from(daily.date) } + + var body: some View { + VStack(spacing: 12) { + summaryRow + hourlyChart + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + } + + private var summaryRow: some View { + HStack(spacing: 16) { + DateBadge(info: dateInfo) + DateDetails(info: dateInfo, modelCount: daily.modelsUsed.count) + Spacer() + CostMetrics(cost: daily.totalCost, tokens: daily.totalTokens) + } + } + + private var hourlyChart: some View { + HourlyCostChartSimple(hourlyData: daily.hourlyCosts, maxScale: maxHourlyCost) + } +} + +// MARK: - Card Components + +private struct DateBadge: View { + let info: DateInfo + + var body: some View { + VStack(spacing: 4) { + dayText + monthText + } + .frame(width: 50) + .padding(.vertical, 8) + .background(info.isToday ? Color.accentColor : Color.gray.opacity(0.2)) + .foregroundColor(info.isToday ? .white : .primary) + .cornerRadius(8) + } + + private var dayText: some View { + Text(info.dayOfMonth) + .font(.title2) + .fontWeight(.bold) + } + + private var monthText: some View { + Text(info.monthAbbreviation) + .font(.caption) + .textCase(.uppercase) + } +} + +private struct DateDetails: View { + let info: DateInfo + let modelCount: Int + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + titleRow + modelCountText + } + } + + private var titleRow: some View { + HStack { + Text(info.dayOfWeek) + .font(.headline) + .lineLimit(1) + todayBadge + } + } + + @ViewBuilder + private var todayBadge: some View { + Badge(text: "TODAY", color: .accentColor) + .opacity(info.isToday ? 1 : 0) + } + + private var modelCountText: some View { + Text("\(modelCount) model\(modelCount == 1 ? "" : "s") used") + .font(.caption) + .foregroundColor(.secondary) + } +} + +private struct CostMetrics: View { + let cost: Double + let tokens: Int + + var body: some View { + VStack(alignment: .trailing, spacing: 4) { + costText + tokenText + } + } + + private var costText: some View { + Text(cost.asCurrency) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + } + + private var tokenText: some View { + Text("\(tokens.abbreviated) tokens") + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .fixedSize() + } +} + +private struct Badge: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.caption2) + .fontWeight(.semibold) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(color.opacity(0.2)) + .foregroundColor(color) + .cornerRadius(4) + } +} + +// MARK: - Pure Transformations + +private struct DateInfo { + let dayOfMonth: String + let monthAbbreviation: String + let dayOfWeek: String + let isToday: Bool + + static func from(_ dateString: String) -> DateInfo { + let components = dateString.split(separator: "-") + let day = components.count == 3 ? String(components[2]) : "" + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + + guard let date = formatter.date(from: dateString) else { + return DateInfo(dayOfMonth: day, monthAbbreviation: "", dayOfWeek: dateString, isToday: false) + } + + formatter.dateFormat = "MMM" + let month = formatter.string(from: date) + + formatter.dateFormat = "EEEE" + let weekday = formatter.string(from: date) + + formatter.dateFormat = "yyyy-MM-dd" + let todayString = formatter.string(from: Date()) + + return DateInfo( + dayOfMonth: day, + monthAbbreviation: month, + dayOfWeek: weekday, + isToday: dateString == todayString + ) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift new file mode 100644 index 0000000..260a1f8 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift @@ -0,0 +1,149 @@ +// +// MainView.swift +// Main window with sidebar navigation +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Notification Names + +public extension Notification.Name { + static let refreshData = Notification.Name("refreshData") +} + +// MARK: - Main View +public struct MainView: View { + @Environment(UsageStore.self) private var store + @State private var selectedDestination: Destination? = .overview + let settingsService: AppSettingsService + + public init(settingsService: AppSettingsService) { + self.settingsService = settingsService + } + + public var body: some View { + NavigationContent( + selectedDestination: $selectedDestination, + store: store, + settingsService: settingsService + ) + } +} + +// MARK: - Navigation Content +private struct NavigationContent: View { + @Binding var selectedDestination: Destination? + let store: UsageStore + let settingsService: AppSettingsService + + var body: some View { + NavigationSplitView { + Sidebar(selectedDestination: $selectedDestination) + } detail: { + DetailView( + destination: selectedDestination ?? .overview, + store: store, + settingsService: settingsService + ) + } + .onReceive(NotificationCenter.default.publisher(for: .refreshData)) { _ in + Task { + await store.loadData() + } + } + } +} + +// MARK: - Sidebar +private struct Sidebar: View { + @Binding var selectedDestination: Destination? + + var body: some View { + List(selection: $selectedDestination) { + NavigationLink(value: Destination.overview) { + Label("Overview", systemImage: "chart.line.uptrend.xyaxis") + } + + NavigationLink(value: Destination.models) { + Label("Models", systemImage: "cpu") + } + + NavigationLink(value: Destination.dailyUsage) { + Label("Daily Usage", systemImage: "calendar") + } + + NavigationLink(value: Destination.analytics) { + Label("Analytics", systemImage: "chart.bar.xaxis") + } + + NavigationLink(value: Destination.liveMetrics) { + Label("Live Metrics", systemImage: "arrow.triangle.2.circlepath") + } + } + .navigationTitle(AppMetadata.name) + .frame(minWidth: 200) + .listStyle(.sidebar) + } +} + +// MARK: - Detail View +private struct DetailView: View { + let destination: Destination + let store: UsageStore + let settingsService: AppSettingsService + + var body: some View { + content + .frame(minWidth: 700) + } + + @ViewBuilder + private var content: some View { + switch destination { + case .overview: + OverviewView() + .environment(store) + case .models: + ModelsView() + .environment(store) + case .dailyUsage: + DailyUsageView() + .environment(store) + case .analytics: + AnalyticsView() + .environment(store) + case .liveMetrics: + MenuBarContentView(settingsService: settingsService, viewMode: .liveMetrics) + .environment(store) + } + } +} + +// MARK: - Navigation Destination +enum Destination: Hashable { + case overview + case models + case dailyUsage + case analytics + case liveMetrics +} + +// MARK: - Preview + +#if DEBUG +struct MainViewPreview: View { + @State private var store = UsageStore() + + var body: some View { + MainView(settingsService: AppSettingsService()) + .environment(store) + .frame(width: 1000, height: 700) + .task { await store.loadData() } + } +} + +#Preview { + MainViewPreview() +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift new file mode 100644 index 0000000..31b758f --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift @@ -0,0 +1,254 @@ +// +// ModelsView.swift +// Model usage breakdown view +// + +import SwiftUI +import ClaudeUsageCore + +struct ModelsView: View { + @Environment(UsageStore.self) private var store + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + ModelsHeader() + ModelsContent(state: ContentState.from(store: store)) + } + .padding() + } + .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Content State + +@MainActor +private enum ContentState { + case loading + case empty + case loaded(models: [ModelUsage], totalCost: Double) + case error + + static func from(store: UsageStore) -> ContentState { + if store.isLoading { return .loading } + guard let stats = store.stats else { return .error } + let sortedModels = stats.byModel.sorted { $0.totalCost > $1.totalCost } + return sortedModels.isEmpty ? .empty : .loaded(models: sortedModels, totalCost: stats.totalCost) + } +} + +// MARK: - Content Router + +private struct ModelsContent: View { + let state: ContentState + + var body: some View { + switch state { + case .loading: + LoadingView(message: "Loading models...") + case .empty: + EmptyStateView( + icon: "cpu", + title: "No Model Data", + message: "Model usage will appear here once you start using Claude Code." + ) + case .loaded(let models, let totalCost): + ModelsList(models: models, totalCost: totalCost) + case .error: + EmptyStateView( + icon: "cpu", + title: "No Data Available", + message: "Unable to load model usage data." + ) + } + } +} + +// MARK: - Header + +private struct ModelsHeader: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + titleView + subtitleView + } + } + + private var titleView: some View { + Text("Model Usage") + .font(.largeTitle) + .fontWeight(.bold) + } + + private var subtitleView: some View { + Text("Breakdown by AI model") + .font(.subheadline) + .foregroundColor(.secondary) + } +} + +// MARK: - Loading View + +private struct LoadingView: View { + let message: String + + var body: some View { + ProgressView(message) + .frame(maxWidth: .infinity) + .padding(.top, 50) + } +} + +// MARK: - List + +private struct ModelsList: View { + let models: [ModelUsage] + let totalCost: Double + + var body: some View { + VStack(spacing: 12) { + ForEach(models) { model in + ModelCard(model: model, totalCost: totalCost) + } + } + } +} + +// MARK: - Model Card + +struct ModelCard: View { + let model: ModelUsage + let totalCost: Double + + private var metrics: ModelMetrics { ModelMetrics.from(model: model, totalCost: totalCost) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + ModelHeader(name: model.model, cost: model.totalCost) + UsageBar(percentage: metrics.percentage, color: metrics.color) + ModelStats(model: model, percentage: metrics.percentage) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + } +} + +// MARK: - Card Components + +private struct ModelHeader: View { + let name: String + let cost: Double + + var body: some View { + HStack { + Label(ModelNameFormatter.format(name), systemImage: "cpu") + .font(.headline) + Spacer() + Text(cost.asCurrency) + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + } + } +} + +private struct UsageBar: View { + let percentage: Double + let color: Color + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.gray.opacity(0.2)) + .frame(height: 8) + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: geometry.size.width * (percentage / 100), height: 8) + } + } + .frame(height: 8) + } +} + +private struct ModelStats: View { + let model: ModelUsage + let percentage: Double + + var body: some View { + HStack { + StatLabel(icon: "doc.text", value: "\(model.sessionCount) sessions") + Spacer() + StatLabel(icon: "number", value: "\(model.tokens.total.abbreviated) tokens") + Spacer() + StatLabel(icon: "percent", value: percentage.asPercentage) + } + } +} + +private struct StatLabel: View { + let icon: String + let value: String + + var body: some View { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption) + .foregroundColor(.secondary) + Text(value) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +// MARK: - Pure Transformations + +private struct ModelMetrics { + let percentage: Double + let color: Color + + static func from(model: ModelUsage, totalCost: Double) -> ModelMetrics { + let pct = totalCost > 0 ? (model.totalCost / totalCost) * 100 : 0 + let color = ModelColorResolver.color(for: model.model) + return ModelMetrics(percentage: pct, color: color) + } +} + +private enum ModelColorResolver { + static func color(for modelName: String) -> Color { + if modelName.contains("opus") { return .purple } + if modelName.contains("sonnet") { return .blue } + if modelName.contains("haiku") { return .green } + return .gray + } +} + +private enum ModelNameFormatter { + private static let knownFamilies = ["opus", "sonnet", "haiku"] + + static func format(_ model: String) -> String { + let parts = model.lowercased().components(separatedBy: "-") + let family = extractFamily(from: parts) + let version = extractVersion(from: parts) + return buildDisplayName(family: family, version: version, fallback: model) + } + + private static func extractFamily(from parts: [String]) -> String? { + parts.first { knownFamilies.contains($0) } + } + + private static func extractVersion(from parts: [String]) -> String { + let numbers = parts.compactMap { Int($0) } + return numbers.count >= 2 + ? "\(numbers[0]).\(numbers[1])" + : numbers.first.map { "\($0)" } ?? "" + } + + private static func buildDisplayName(family: String?, version: String, fallback: String) -> String { + guard let family = family else { return fallback } + let capitalizedFamily = family.capitalized + return version.isEmpty ? "Claude \(capitalizedFamily)" : "Claude \(capitalizedFamily) \(version)" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift new file mode 100644 index 0000000..a7e4ec1 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift @@ -0,0 +1,30 @@ +// +// MetricCard.swift +// Reusable metric card component +// + +import SwiftUI + +struct MetricCard: View { + let title: String + let value: String + let icon: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label(title, systemImage: icon) + .font(.caption) + .foregroundColor(.secondary) + + Text(value) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(color.opacity(0.1)) + .cornerRadius(12) + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift new file mode 100644 index 0000000..f52a9f4 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift @@ -0,0 +1,229 @@ +// +// OverviewView.swift +// Overview dashboard view +// + +import SwiftUI +import ClaudeUsageCore + +struct OverviewView: View { + @Environment(UsageStore.self) private var store + + var body: some View { + ScrollView { + OverviewContent(state: ContentState.from(store: store)) + } + .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Content State + +@MainActor +private enum ContentState { + case loading + case loaded(UsageStats) + case error + + static func from(store: UsageStore) -> ContentState { + if store.isLoading { return .loading } + guard let stats = store.stats else { return .error } + return .loaded(stats) + } +} + +// MARK: - Content Router + +private struct OverviewContent: View { + let state: ContentState + + var body: some View { + switch state { + case .loading: + LoadingView(message: "Loading...") + case .loaded(let stats): + LoadedContent(stats: stats) + case .error: + EmptyStateView( + icon: "chart.line.uptrend.xyaxis", + title: "No Data Available", + message: "Run Claude Code sessions to generate usage data." + ) + } + } +} + +private struct LoadedContent: View { + let stats: UsageStats + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + OverviewHeader() + MetricsGrid(stats: stats) + CostBreakdownSection(stats: stats) + } + .padding() + } +} + +// MARK: - Header + +private struct OverviewHeader: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + titleView + subtitleView + } + } + + private var titleView: some View { + Text("Overview") + .font(.largeTitle) + .fontWeight(.bold) + } + + private var subtitleView: some View { + Text("Your Claude Code usage at a glance") + .font(.subheadline) + .foregroundColor(.secondary) + } +} + +// MARK: - Loading View + +private struct LoadingView: View { + let message: String + + var body: some View { + ProgressView(message) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.top, 100) + } +} + +// MARK: - Metrics Grid + +private struct MetricsGrid: View { + let stats: UsageStats + + private let columns = [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + + var body: some View { + LazyVGrid(columns: columns, spacing: 16) { + totalCostCard + totalSessionsCard + totalTokensCard + avgCostCard + } + } + + private var totalCostCard: some View { + MetricCard(title: "Total Cost", value: stats.totalCost.asCurrency, icon: "dollarsign.circle", color: .green) + } + + private var totalSessionsCard: some View { + MetricCard(title: "Total Sessions", value: "\(stats.sessionCount)", icon: "doc.text", color: .blue) + } + + private var totalTokensCard: some View { + MetricCard(title: "Total Tokens", value: stats.totalTokens.abbreviated, icon: "number", color: .purple) + } + + private var avgCostCard: some View { + MetricCard(title: "Avg Cost/Session", value: stats.averageCostPerSession.asCurrency, icon: "chart.line.uptrend.xyaxis", color: .orange) + } +} + +// MARK: - Cost Breakdown Section + +private struct CostBreakdownSection: View { + let stats: UsageStats + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle + breakdownList + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(12) + } + + private var sectionTitle: some View { + Text("Cost Breakdown by Model") + .font(.headline) + } + + private var breakdownList: some View { + VStack(spacing: 8) { + ForEach(UsageAnalytics.costBreakdown(from: stats), id: \.model) { item in + CostBreakdownRow(item: item) + } + } + } +} + +// MARK: - Cost Breakdown Row + +private struct CostBreakdownRow: View { + let item: (model: String, cost: Double, percentage: Double) + + var body: some View { + HStack { + modelNameText + Spacer() + percentageText + costText + } + .padding(.vertical, 4) + } + + private var modelNameText: some View { + Text(ModelNameFormatter.format(item.model)) + .font(.subheadline) + } + + private var percentageText: some View { + Text(item.percentage.asPercentage) + .font(.caption) + .foregroundColor(.secondary) + } + + private var costText: some View { + Text(item.cost.asCurrency) + .font(.system(.body, design: .monospaced)) + .frame(minWidth: 80, alignment: .trailing) + } +} + +// MARK: - Pure Transformations + +private enum ModelNameFormatter { + /// Formats model ID to display name: "claude-opus-4-5-20251101" → "Claude Opus 4.5" + static func format(_ model: String) -> String { + let parts = model.lowercased().components(separatedBy: "-") + let family = extractFamily(from: parts) + let version = extractVersion(from: parts) + return buildDisplayName(family: family, version: version, fallback: model) + } + + private static func extractFamily(from parts: [String]) -> String? { + parts.first { ["opus", "sonnet", "haiku"].contains($0) } + } + + private static func extractVersion(from parts: [String]) -> String { + let numbers = parts.compactMap { Int($0) } + return numbers.count >= 2 + ? "\(numbers[0]).\(numbers[1])" + : numbers.first.map { "\($0)" } ?? "" + } + + private static func buildDisplayName(family: String?, version: String, fallback: String) -> String { + guard let family else { return fallback } + let name = "Claude \(family.capitalized)" + return version.isEmpty ? name : "\(name) \(version)" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift new file mode 100644 index 0000000..fc8f0e2 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift @@ -0,0 +1,134 @@ +// +// ActionButtons.swift +// Action buttons component for menu bar +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - ActionButtons + +struct ActionButtons: View { + @Environment(\.openWindow) private var openWindow + let settingsService: AppSettingsService + let onRefresh: () -> Void + let viewMode: MenuBarViewMode + + var body: some View { + HStack(spacing: 12) { + dashboardButton + refreshButton + menuBarOnlyButtons + } + .padding(.bottom, MenuBarTheme.Layout.actionButtonsBottomPadding) + } + + // MARK: - Button Components + + @ViewBuilder + private var dashboardButton: some View { + if viewMode == .menuBar { + Button("Main") { + openMainWindow() + } + .buttonStyle(MenuButtonStyle(style: .primary)) + .keyboardShortcut("1", modifiers: .command) + .help("Open the main window (⌘1)") + } + } + + private var refreshButton: some View { + Button("Refresh") { + onRefresh() + } + .buttonStyle(MenuButtonStyle(style: .primary)) + .keyboardShortcut("r", modifiers: .command) + .help("Refresh usage data (⌘R)") + } + + @ViewBuilder + private var menuBarOnlyButtons: some View { + if viewMode == .menuBar { + settingsMenuButton + Spacer() + quitButton + } + } + + private var settingsMenuButton: some View { + SettingsMenu(settingsService: settingsService) + .menuStyle(BorderlessButtonMenuStyle()) + .fixedSize() + .buttonStyle(MenuButtonStyle(style: .secondary)) + .help("Settings") + } + + private var quitButton: some View { + Button("Quit") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(MenuButtonStyle(style: .secondary)) + .keyboardShortcut("q", modifiers: .command) + .help("Quit the application (⌘Q)") + } + + // MARK: - Actions + + private func openMainWindow() { + // Capture screen at click time (before async operations) + let targetScreen = screenAtMouseLocation() + + // Always call openWindow - SwiftUI handles create vs activate + openWindow(id: "main") + NSApp.activate(ignoringOtherApps: true) + + // Wait for window to be created, then position + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + guard let window = findMainWindow() else { return } + + // Move window to current Space (not just current screen) + window.collectionBehavior.insert(.moveToActiveSpace) + + if window.isMiniaturized { + window.deminiaturize(nil) + } + window.makeKeyAndOrderFront(nil) + + // Set frame AFTER makeKeyAndOrderFront to override SwiftUI positioning + DispatchQueue.main.async { + centerWindow(window, on: targetScreen) + } + } + } +} + +// MARK: - Window Helpers + +@MainActor +private func findMainWindow() -> NSWindow? { + NSApp.windows.first { $0.title == AppMetadata.name } +} + +@MainActor +private func screenAtMouseLocation() -> NSScreen? { + let mouseLocation = NSEvent.mouseLocation + return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + ?? NSScreen.main +} + +@MainActor +private func centerWindow(_ window: NSWindow, on screen: NSScreen?) { + guard let screen = screen else { return } + let screenFrame = screen.visibleFrame + let windowSize = window.frame.size + let x = screenFrame.midX - windowSize.width / 2 + let y = screenFrame.midY - windowSize.height / 2 + window.setFrame(NSRect(x: x, y: y, width: windowSize.width, height: windowSize.height), display: true) +} + +// MARK: - Supporting Types + +enum MenuBarViewMode { + case menuBar + case liveMetrics +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift new file mode 100644 index 0000000..17c4cb0 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift @@ -0,0 +1,244 @@ +// +// GraphView.swift +// Enhanced graph component with area fill and grid +// + +import SwiftUI + +// MARK: - Constants + +private enum Constants { + static let coordinatePadding: CGFloat = 4 + static let uniformNormalizedValue: Double = 0.5 +} + +// MARK: - GraphView + +struct GraphView: View { + let dataPoints: [Double] + let color: Color + let showDots: Bool = false + + var body: some View { + GeometryReader { geometry in + ZStack { + backgroundView + chartContentView(in: geometry) + } + } + .frame(height: MenuBarTheme.Layout.graphHeight) + } +} + +// MARK: - Background + +private extension GraphView { + var backgroundView: some View { + RoundedRectangle(cornerRadius: MenuBarTheme.Layout.graphCornerRadius) + .fill(MenuBarTheme.Colors.UI.background) + .overlay(backgroundBorder) + } + + var backgroundBorder: some View { + RoundedRectangle(cornerRadius: MenuBarTheme.Layout.graphCornerRadius) + .stroke(MenuBarTheme.Colors.UI.trackBorder, lineWidth: MenuBarTheme.Graph.strokeWidth) + } +} + +// MARK: - Chart Content + +private extension GraphView { + @ViewBuilder + func chartContentView(in geometry: GeometryProxy) -> some View { + if hasEnoughDataPoints { + let normalizedData = normalizedDataPoints + gridLinesView(in: geometry) + areaFillView(for: normalizedData, in: geometry) + lineGraphView(for: normalizedData, in: geometry) + dataDotsView(for: normalizedData, in: geometry) + } + } + + var hasEnoughDataPoints: Bool { + dataPoints.count > 1 + } + + @ViewBuilder + func dataDotsView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { + if showDots { + dataDots(for: normalizedData, in: geometry) + } + } +} + +// MARK: - Data Normalization + +private extension GraphView { + var normalizedDataPoints: [Double] { + guard !dataPoints.isEmpty else { return [] } + return normalizeToRange(dataPoints, range: dataRange) + } + + var dataRange: DataRange { + DataRange( + minimum: dataPoints.min() ?? 0.0, + maximum: dataPoints.max() ?? 1.0 + ) + } + + func normalizeToRange(_ points: [Double], range: DataRange) -> [Double] { + guard range.span > 0 else { + return uniformNormalizedValues(count: points.count) + } + return points.map { range.normalize($0) } + } + + func uniformNormalizedValues(count: Int) -> [Double] { + Array(repeating: Constants.uniformNormalizedValue, count: count) + } +} + +// MARK: - Data Range + +private struct DataRange { + let minimum: Double + let maximum: Double + + var span: Double { maximum - minimum } + + func normalize(_ value: Double) -> Double { + (value - minimum) / span + } +} + +// MARK: - Coordinate Calculation + +private extension GraphView { + func calculateCoordinates(for normalizedData: [Double], in size: CGSize) -> [CGPoint] { + guard normalizedData.count > 1 else { return [] } + let xStep = calculateXStep(for: normalizedData.count, width: size.width) + return normalizedData.enumerated().map { index, value in + calculatePoint(at: index, value: value, xStep: xStep, size: size) + } + } + + func calculateXStep(for count: Int, width: CGFloat) -> CGFloat { + width / CGFloat(count - 1) + } + + func calculatePoint(at index: Int, value: Double, xStep: CGFloat, size: CGSize) -> CGPoint { + let x = CGFloat(index) * xStep + let y = calculateY(for: value, height: size.height) + return CGPoint(x: x, y: y) + } + + func calculateY(for normalizedValue: Double, height: CGFloat) -> CGFloat { + let padding = Constants.coordinatePadding + let availableHeight = height - padding * 2 + return padding + (1.0 - normalizedValue) * availableHeight + } +} + +// MARK: - Grid Lines + +private extension GraphView { + func gridLinesView(in geometry: GeometryProxy) -> some View { + Path { path in + gridLineYPositions(in: geometry.size).forEach { y in + path.addHorizontalLine(at: y, width: geometry.size.width) + } + } + .stroke(MenuBarTheme.Colors.UI.gridLines, lineWidth: MenuBarTheme.Graph.strokeWidth) + } + + func gridLineYPositions(in size: CGSize) -> [CGFloat] { + (1.. CGFloat { + height * (CGFloat(index) / CGFloat(MenuBarTheme.Layout.gridLineCount)) + } +} + +// MARK: - Line Graph + +private extension GraphView { + func lineGraphView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { + Path { path in + path.addLineGraph(through: calculateCoordinates(for: normalizedData, in: geometry.size)) + } + .stroke(color, lineWidth: MenuBarTheme.Graph.lineWidth) + } +} + +// MARK: - Area Fill + +private extension GraphView { + func areaFillView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { + Path { path in + path.addClosedArea( + through: calculateCoordinates(for: normalizedData, in: geometry.size), + bottomY: geometry.size.height, + width: geometry.size.width + ) + } + .fill(areaGradient) + } + + var areaGradient: LinearGradient { + LinearGradient( + colors: areaGradientColors, + startPoint: .top, + endPoint: .bottom + ) + } + + var areaGradientColors: [Color] { + [ + color.opacity(MenuBarTheme.Graph.areaGradientTopOpacity), + color.opacity(MenuBarTheme.Graph.areaGradientBottomOpacity) + ] + } +} + +// MARK: - Data Dots + +private extension GraphView { + func dataDots(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { + let coordinates = calculateCoordinates(for: normalizedData, in: geometry.size) + return ForEach(coordinates.indices, id: \.self) { index in + dataDot(at: coordinates[index]) + } + } + + func dataDot(at point: CGPoint) -> some View { + Circle() + .fill(color) + .frame(width: MenuBarTheme.Layout.dataDotSize, height: MenuBarTheme.Layout.dataDotSize) + .position(point) + } +} + +// MARK: - Path Extensions + +private extension Path { + mutating func addHorizontalLine(at y: CGFloat, width: CGFloat) { + move(to: CGPoint(x: 0, y: y)) + addLine(to: CGPoint(x: width, y: y)) + } + + mutating func addLineGraph(through points: [CGPoint]) { + guard let first = points.first else { return } + move(to: first) + points.dropFirst().forEach { addLine(to: $0) } + } + + mutating func addClosedArea(through points: [CGPoint], bottomY: CGFloat, width: CGFloat) { + move(to: CGPoint(x: 0, y: bottomY)) + points.forEach { addLine(to: $0) } + addLine(to: CGPoint(x: width, y: bottomY)) + closeSubpath() + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift new file mode 100644 index 0000000..09e585c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift @@ -0,0 +1,147 @@ +// +// MetricRow.swift +// Metric display row with progress bar and optional trend graph +// + +import SwiftUI + +@available(macOS 13.0, *) +struct MetricRow: View { + let title: String + let value: String + let subvalue: String? + let percentage: Double + let segments: [ProgressSegment] + let trendData: [Double]? + let showWarning: Bool + + var body: some View { + VStack(alignment: .leading, spacing: MenuBarTheme.Layout.itemSpacing) { + headerRow + progressBarSection + } + .padding(.vertical, MenuBarTheme.Layout.verticalPadding) + } + + // MARK: - Header Row + + private var headerRow: some View { + HStack { + titleSection + Spacer() + graphSection + valueSection + } + } + + // MARK: - Graph Section + + @ViewBuilder + private var graphSection: some View { + if let data = trendData, data.count > 1 { + GraphView(dataPoints: data, color: percentageColor) + .frame( + width: MenuBarTheme.Layout.graphWidth, + height: MenuBarTheme.Layout.graphHeight + ) + .padding(.trailing, 6) + } + } + + // MARK: - Progress Bar Section + + private var progressBarSection: some View { + ProgressBar( + value: progressBarValue, + segments: segments, + showOverflow: isOverLimit + ) + } + + // MARK: - Pure Computations + + private var progressBarValue: Double { + min(percentage / 100.0, 1.5) + } + + private var isOverLimit: Bool { + percentage > 100 + } + + private var shouldShowWarningIcon: Bool { + showWarning && percentage >= 100 + } + + private var percentageColor: Color { + ColorService.colorForPercentage(percentage) + } + + // MARK: - Title Section + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 2) { + titleLabel + percentageRow + } + } + + private var titleLabel: some View { + Text(title) + .font(MenuBarTheme.Typography.metricTitle) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + } + + private var percentageRow: some View { + HStack(spacing: 6) { + percentageLabel + warningIcon + } + } + + private var percentageLabel: some View { + Text(FormatterService.formatPercentage(percentage)) + .font(MenuBarTheme.Typography.metricValue) + .foregroundColor(percentageColor) + .monospacedDigit() + } + + @ViewBuilder + private var warningIcon: some View { + if shouldShowWarningIcon { + Image(systemName: "flame.fill") + .font(MenuBarTheme.Typography.warningIcon) + .foregroundColor(MenuBarTheme.Colors.Status.critical) + } + } + + // MARK: - Value Section + + private var valueSection: some View { + VStack(alignment: .trailing, spacing: 2) { + primaryValueLabel + subvalueLabel + } + .fixedSize(horizontal: false, vertical: true) + } + + private var primaryValueLabel: some View { + Text(value) + .font(MenuBarTheme.Typography.metricValue.weight(.medium)) + .foregroundColor(MenuBarTheme.Colors.UI.primaryText) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + @ViewBuilder + private var subvalueLabel: some View { + if let subvalue { + Text(subvalue) + .font(MenuBarTheme.Typography.metricSubvalue) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift new file mode 100644 index 0000000..4052486 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift @@ -0,0 +1,92 @@ +// +// ProgressBar.swift +// Enhanced progress bar component with overflow support +// + +import SwiftUI + +@available(macOS 13.0, *) +struct ProgressBar: View { + let value: Double + let segments: [ProgressSegment] + let showOverflow: Bool + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + backgroundTrack + progressFill(width: geometry.size.width) + overflowIndicator + } + } + .frame(height: MenuBarTheme.Layout.progressBarHeight) + } + + // MARK: - View Components + + private var backgroundTrack: some View { + RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius) + .fill(MenuBarTheme.Colors.UI.trackBackground) + .overlay(trackBorder) + } + + private var trackBorder: some View { + RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius) + .stroke(MenuBarTheme.Colors.UI.trackBorder, lineWidth: MenuBarTheme.Graph.strokeWidth) + } + + private func progressFill(width: CGFloat) -> some View { + HStack(spacing: 0) { + ForEach(segments.indices, id: \.self) { index in + segmentView(for: segments[index], containerWidth: width) + } + } + .clipShape(RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius)) + } + + @ViewBuilder + private func segmentView(for segment: ProgressSegment, containerWidth: CGFloat) -> some View { + let fillValue = segmentFillValue(for: segment) + if fillValue > 0 { + Rectangle() + .fill(segmentGradient(for: segment)) + .frame(width: min(fillValue * containerWidth, containerWidth)) + } + } + + @ViewBuilder + private var overflowIndicator: some View { + if shouldShowOverflowIndicator { + HStack { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .font(MenuBarTheme.Typography.overflowIcon) + .foregroundColor(MenuBarTheme.Colors.Status.critical) + .offset(x: 2) + } + } + } + + // MARK: - Pure Functions + + private var shouldShowOverflowIndicator: Bool { + showOverflow && value > 1.0 + } + + private func segmentFillValue(for segment: ProgressSegment) -> Double { + let rangeLength = segment.range.upperBound - segment.range.lowerBound + let clampedValue = min(max(0, value - segment.range.lowerBound), rangeLength) + return clampedValue + } + + private func segmentGradient(for segment: ProgressSegment) -> LinearGradient { + LinearGradient( + colors: [ + segment.color.opacity(MenuBarTheme.Graph.progressGradientStartOpacity), + segment.color.opacity(MenuBarTheme.Graph.progressGradientEndOpacity) + ], + startPoint: .leading, + endPoint: .trailing + ) + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift new file mode 100644 index 0000000..3026665 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift @@ -0,0 +1,47 @@ +// +// SectionHeader.swift +// Section header component with icon and optional badge +// + +import SwiftUI + +@available(macOS 13.0, *) +struct SectionHeader: View { + let title: String + let icon: String + let color: Color + let badge: String? + + var body: some View { + HStack(spacing: MenuBarTheme.Layout.itemSpacing) { + Image(systemName: icon) + .font(MenuBarTheme.Typography.sectionIcon) + .foregroundColor(color) + + Text(title.uppercased()) + .font(MenuBarTheme.Typography.sectionTitle) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + .kerning(MenuBarTheme.Typography.sectionTitleKerning) + + if let badge = badge { + badgeView(badge) + } + + Spacer() + } + .padding(.horizontal, MenuBarTheme.Layout.contentHorizontalPadding) + .padding(.vertical, MenuBarTheme.Layout.verticalPadding) + .background(MenuBarTheme.Colors.UI.sectionBackground) + } + + // MARK: - Badge View + private func badgeView(_ text: String) -> some View { + Text(text) + .font(MenuBarTheme.Typography.badgeText) + .foregroundColor(.white) + .padding(.horizontal, MenuBarTheme.Badge.horizontalPadding) + .padding(.vertical, MenuBarTheme.Badge.verticalPadding) + .background(color) + .cornerRadius(MenuBarTheme.Badge.cornerRadius) + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift new file mode 100644 index 0000000..45854f1 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift @@ -0,0 +1,85 @@ +// +// SettingsMenu.swift +// Reusable settings menu component with proper separation of concerns +// + +import SwiftUI + +struct SettingsMenu: View { + let settingsService: AppSettingsService + @State private var showingError = false + @State private var lastError: AppSettingsError? + + let label: () -> Label + + init( + settingsService: AppSettingsService, + @ViewBuilder label: @escaping () -> Label + ) { + self.settingsService = settingsService + self.label = label + } + + var body: some View { + Menu { + // Open at Login Toggle + Toggle("Open at Login", isOn: openAtLoginBinding) + + Divider() + + // About + Button("About \(settingsService.appName)") { + settingsService.showAboutPanel() + } + } label: { + label() + } + .alert( + "Settings Error", + isPresented: $showingError, + presenting: lastError + ) { _ in + Button("OK", role: .cancel) { } + } message: { error in + Text(error.localizedDescription) + if let suggestion = error.recoverySuggestion { + Text(suggestion) + } + } + } + + private var openAtLoginBinding: Binding { + Binding( + get: { settingsService.isOpenAtLoginEnabled }, + set: { newValue in + Task { + let result = await settingsService.setOpenAtLogin(newValue) + if case .failure(let error) = result { + lastError = error + showingError = true + } + } + } + ) + } +} + +// MARK: - Convenience Initializers + +extension SettingsMenu { + /// Creates a settings menu with a gear icon + init(settingsService: AppSettingsService) where Label == Image { + self.init(settingsService: settingsService) { + Image(systemName: "gearshape.fill") + } + } +} + +extension SettingsMenu where Label == Text { + /// Creates a settings menu with text label + init(_ title: String, settingsService: AppSettingsService) { + self.init(settingsService: settingsService) { + Text(title) + } + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift new file mode 100644 index 0000000..d042f6e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift @@ -0,0 +1,122 @@ +// +// ColorService.swift +// Business logic for color determination based on performance metrics +// + +import SwiftUI + +@available(macOS 13.0, *) +struct ColorService { + + // MARK: - Public API + + static func colorForPercentage(_ percentage: Double) -> Color { + color(for: percentage, using: percentageThresholds) + } + + static func colorForCostProgress(_ progress: Double) -> Color { + color(for: progress, using: costProgressThresholds) + } + + static func sessionTimeSegments() -> [ProgressSegment] { + timeSegmentRanges + } + + static func sessionTokenSegments() -> [ProgressSegment] { + tokenSegmentRanges + } + + static func singleColorSegment(color: Color) -> [ProgressSegment] { + [ProgressSegment(range: 0...1.0, color: color)] + } + + // MARK: - Threshold Lookup (Pure Function) + + private static func color( + for value: Double, + using thresholds: [ColorThreshold] + ) -> Color { + thresholds + .first { value < $0.upperBound } + .map(\.color) + ?? MenuBarTheme.Colors.Status.critical + } + + // MARK: - Threshold Definitions + + private static var percentageThresholds: [ColorThreshold] { + [ + ColorThreshold( + upperBound: MenuBarTheme.Thresholds.Percentage.low, + color: MenuBarTheme.Colors.Status.active + ), + ColorThreshold( + upperBound: MenuBarTheme.Thresholds.Percentage.high, + color: MenuBarTheme.Colors.Status.warning + ), + ] + } + + private static var costProgressThresholds: [ColorThreshold] { + [ + ColorThreshold( + upperBound: MenuBarTheme.Thresholds.Cost.normal, + color: MenuBarTheme.Colors.Status.active + ), + ColorThreshold( + upperBound: MenuBarTheme.Thresholds.Cost.critical, + color: MenuBarTheme.Colors.Status.warning + ), + ] + } + + // MARK: - Segment Definitions + + private static var timeSegmentRanges: [ProgressSegment] { + [ + ProgressSegment( + range: 0...MenuBarTheme.Thresholds.Sessions.timeSegments.low, + color: MenuBarTheme.Colors.ProgressSegments.green + ), + ProgressSegment( + range: MenuBarTheme.Thresholds.Sessions.timeSegments.low...MenuBarTheme.Thresholds.Sessions.timeSegments.medium, + color: MenuBarTheme.Colors.ProgressSegments.orange + ), + ProgressSegment( + range: MenuBarTheme.Thresholds.Sessions.timeSegments.medium...MenuBarTheme.Thresholds.Sessions.timeSegments.max, + color: MenuBarTheme.Colors.ProgressSegments.red + ), + ] + } + + private static var tokenSegmentRanges: [ProgressSegment] { + [ + ProgressSegment( + range: 0...MenuBarTheme.Thresholds.Sessions.tokenSegments.low, + color: MenuBarTheme.Colors.ProgressSegments.blue + ), + ProgressSegment( + range: MenuBarTheme.Thresholds.Sessions.tokenSegments.low...MenuBarTheme.Thresholds.Sessions.tokenSegments.medium, + color: MenuBarTheme.Colors.ProgressSegments.purple + ), + ProgressSegment( + range: MenuBarTheme.Thresholds.Sessions.tokenSegments.medium...MenuBarTheme.Thresholds.Sessions.tokenSegments.max, + color: MenuBarTheme.Colors.ProgressSegments.red + ), + ] + } +} + +// MARK: - Supporting Types + +@available(macOS 13.0, *) +private struct ColorThreshold { + let upperBound: Double + let color: Color +} + +@available(macOS 13.0, *) +struct ProgressSegment { + let range: ClosedRange + let color: Color +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift new file mode 100644 index 0000000..ac3db2b --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift @@ -0,0 +1,149 @@ +// +// FormatterService.swift +// Formatting utilities for display values +// + +import Foundation + +struct FormatterService { + + // MARK: - Token Formatting + + static func formatTokenCount(_ count: Int) -> String { + TokenThreshold.format(count) + } + + // MARK: - Percentage Formatting + + static func formatPercentage(_ percentage: Double) -> String { + "\(Int(percentage))%" + } + + // MARK: - Time Duration Formatting + + static func formatTimeInterval(_ interval: TimeInterval, totalInterval: TimeInterval) -> String { + let hours = interval / TimeConstants.secondsPerHour + let totalHours = totalInterval / TimeConstants.secondsPerHour + return String(format: "%.1fh / %.0fh", hours, totalHours) + } + + /// Format countdown time like "Resets in 3 hr 24 min" + static func formatCountdown(_ interval: TimeInterval) -> String { + guard interval > 0 else { return "Resetting..." } + let hours = Int(interval / TimeConstants.secondsPerHour) + let minutes = Int((interval.truncatingRemainder(dividingBy: TimeConstants.secondsPerHour)) / TimeConstants.secondsPerMinute) + if hours > 0 { + return "Resets in \(hours) hr \(minutes) min" + } else { + return "Resets in \(minutes) min" + } + } + + // MARK: - Currency Formatting + + static func formatCurrency(_ amount: Double) -> String { + String(format: "$%.2f", amount) + } + + // MARK: - Rate Formatting + + static func formatTokenRate(_ tokensPerMinute: Int) -> String { + "\(formatTokenCount(tokensPerMinute)) tokens/min" + } + + static func formatCostRate(_ costPerHour: Double) -> String { + "\(formatCurrency(costPerHour))/hr" + } + + // MARK: - Session Count Formatting + + static func formatSessionCount(_ count: Int) -> String { + count > 0 ? "\(count) active" : "No active" + } + + // MARK: - Value with Limit Formatting + + static func formatValueWithLimit(_ current: T, limit: T) -> String { + "\(formatTokenCount(Int(current))) / \(formatTokenCount(Int(limit)))" + } + + // MARK: - Daily Average Formatting + + static func formatDailyAverage(_ average: Double) -> String { + formatCurrency(average) + } + + // MARK: - Large Number Formatting + + static func formatLargeNumber(_ number: Int) -> String { + formatTokenCount(number) + } + + // MARK: - Relative Time Formatting + + static func formatRelativeTime(_ date: Date?) -> String { + guard let date else { return "Never" } + let interval = Date().timeIntervalSince(date) + return RelativeTimeThreshold.format(interval) + } +} + +// MARK: - Token Threshold Configuration + +private enum TokenThreshold { + typealias Threshold = (minimum: Int, divisor: Double, suffix: String, format: String) + + static let thresholds: [Threshold] = [ + (minimum: 1_000_000, divisor: 1_000_000, suffix: "M", format: "%.1f"), + (minimum: 1_000, divisor: 1_000, suffix: "K", format: "%.1f") + ] + + static func format(_ count: Int) -> String { + thresholds + .first { count >= $0.minimum } + .map { formatWithThreshold(count, threshold: $0) } + ?? "\(count)" + } + + private static func formatWithThreshold(_ count: Int, threshold: Threshold) -> String { + let value = Double(count) / threshold.divisor + return String(format: "\(threshold.format)\(threshold.suffix)", value) + } +} + +// MARK: - Relative Time Threshold Configuration + +private enum RelativeTimeThreshold { + typealias Threshold = (maxInterval: TimeInterval, divisor: TimeInterval, suffix: String) + + static let thresholds: [Threshold] = [ + (maxInterval: TimeConstants.secondsPerMinute, divisor: 1, suffix: ""), + (maxInterval: TimeConstants.secondsPerHour, divisor: TimeConstants.secondsPerMinute, suffix: "m ago"), + (maxInterval: TimeConstants.secondsPerDay, divisor: TimeConstants.secondsPerHour, suffix: "h ago") + ] + + static func format(_ interval: TimeInterval) -> String { + thresholds + .first { interval < $0.maxInterval } + .map { formatWithThreshold(interval, threshold: $0) } + ?? formatDays(interval) + } + + private static func formatWithThreshold(_ interval: TimeInterval, threshold: Threshold) -> String { + threshold.suffix.isEmpty + ? "Just now" + : "\(Int(interval / threshold.divisor))\(threshold.suffix)" + } + + private static func formatDays(_ interval: TimeInterval) -> String { + "\(Int(interval / TimeConstants.secondsPerDay))d ago" + } +} + +// MARK: - Time Constants + +private enum TimeConstants { + static let secondsPerMinute: TimeInterval = 60 + static let secondsPerHour: TimeInterval = 3600 + static let secondsPerDay: TimeInterval = 86400 +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift new file mode 100644 index 0000000..ca4f475 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift @@ -0,0 +1,158 @@ +// +// MenuBar.swift +// Refactored professional menu bar UI with clean architecture +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Main Menu Bar Content View +struct MenuBarContentView: View { + @Environment(UsageStore.self) private var store + let settingsService: AppSettingsService + @FocusState private var focusedField: FocusField? + let viewMode: MenuBarViewMode + + enum FocusField: Hashable { + case refresh + case settings + case quit + } + + // MARK: - Initializers + init(settingsService: AppSettingsService, viewMode: MenuBarViewMode = .menuBar) { + self.settingsService = settingsService + self.viewMode = viewMode + } + + var body: some View { + VStack(spacing: 0) { + liveSessionSection + usageSection + costSection + actionsSection + } + .frame(width: MenuBarTheme.Layout.menuBarWidth) + .background(MenuBarTheme.Colors.UI.background) + .focusable() + .onKeyPress { handleKeyPress($0) } + } + + // MARK: - Sections + + @ViewBuilder + private var liveSessionSection: some View { + if let session = store.activeSession { + let _ = session // Suppress unused warning + SectionHeader( + title: "Live Session", + icon: "dot.radiowaves.left.and.right", + color: MenuBarTheme.Colors.Sections.liveSession, + badge: "ACTIVE" + ) + SessionMetricsSection() + .padding(.horizontal, 12) + .padding(.vertical, 4) + sectionDivider + .padding(.horizontal, 12) + } + } + + private var usageSection: some View { + Group { + SectionHeader( + title: "Usage", + icon: "chart.bar.fill", + color: MenuBarTheme.Colors.Sections.usage, + badge: nil + ) + UsageMetricsSection() + .padding(.horizontal, 12) + .padding(.vertical, 4) + sectionDivider + .padding(.horizontal, 12) + } + } + + private var costSection: some View { + Group { + SectionHeader( + title: "Cost", + icon: "dollarsign.circle.fill", + color: MenuBarTheme.Colors.Sections.cost, + badge: nil + ) + CostMetricsSection() + .padding(.horizontal, 12) + .padding(.vertical, 4) + sectionDivider + .padding(.horizontal, 12) + } + } + + private var actionsSection: some View { + ActionButtons(settingsService: settingsService, onRefresh: handleRefresh, viewMode: viewMode) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + + // MARK: - Keyboard Handling + + private func handleKeyPress(_ press: KeyPress) -> KeyPress.Result { + switch press.key { + case .tab: + press.modifiers.contains(.shift) ? switchFocusPrevious() : switchFocusNext() + return .handled + case .escape: + if viewMode == .menuBar { NSApp.hide(nil) } + return .handled + case KeyEquivalent("r") where press.modifiers.contains(.command): + handleRefresh() + return .handled + default: + return .ignored + } + } + + // MARK: - Focus Navigation + private func switchFocusNext() { + switch focusedField { + case .refresh: + focusedField = .settings + case .settings: + focusedField = .quit + case .quit, nil: + focusedField = .refresh + } + } + + private func switchFocusPrevious() { + switch focusedField { + case .refresh: + focusedField = .quit + case .settings: + focusedField = .refresh + case .quit, nil: + focusedField = .settings + } + } + + // MARK: - UI Elements + private var sectionDivider: some View { + Divider() + .padding(.vertical, MenuBarTheme.Layout.dividerVerticalPadding) + } + + // MARK: - Actions + private func handleRefresh() { + Task { + await store.loadData() + } + } +} + +// MARK: - Backward Compatibility Aliases +// These maintain compatibility with existing code that may reference the old component names +typealias ImprovedProgressBar = ProgressBar +typealias EnhancedGraphView = GraphView +typealias ImprovedSectionHeader = SectionHeader diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift new file mode 100644 index 0000000..0ea065a --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift @@ -0,0 +1,282 @@ +// +// MenuBarScene.swift +// Menu bar scene and supporting views +// + +import SwiftUI +import ClaudeUsageCore + +// MARK: - Menu Bar Scene + +public struct MenuBarScene: Scene { + let store: UsageStore + let settingsService: AppSettingsService + let lifecycleManager: AppLifecycleManager + @State private var hasInitialized = false + + public init(store: UsageStore, settingsService: AppSettingsService, lifecycleManager: AppLifecycleManager) { + self.store = store + self.settingsService = settingsService + self.lifecycleManager = lifecycleManager + } + + public var body: some Scene { + MenuBarExtra { + menuContent + } label: { + menuLabel + } + .menuBarExtraStyle(.window) + } + + private var menuContent: some View { + MenuBarContentView(settingsService: settingsService) + .environment(store) + } + + private var menuLabel: some View { + MenuBarLabel(store: store) + .environment(store) + .task { await initializeOnce() } + .contextMenu { contextMenu } + } + + private var contextMenu: some View { + MenuBarContextMenu(settingsService: settingsService) + .environment(store) + } + + private func initializeOnce() async { + guard !hasInitialized else { return } + hasInitialized = true + lifecycleManager.configure(with: store) + await store.initializeIfNeeded() + } +} + +// MARK: - Menu Bar Label + +struct MenuBarLabel: View { + let store: UsageStore + + var body: some View { + HStack(spacing: 4) { + iconView + costText + } + } + + private var iconView: some View { + Image(systemName: appearance.icon) + .foregroundColor(appearance.color) + } + + private var costText: some View { + Text(store.formattedTodaysCost) + .font(.system(.body, design: .monospaced)) + } + + private var appearance: MenuBarAppearance { + MenuBarAppearance.from(store: store) + } +} + +// MARK: - Menu Bar Context Menu + +struct MenuBarContextMenu: View { + @Environment(UsageStore.self) private var store + let settingsService: AppSettingsService + + var body: some View { + Group { + refreshSection + Divider() + statusSection + Divider() + actionsSection + Divider() + quitButton + } + } + + private var refreshSection: some View { + Button("Refresh") { + Task { await store.loadData() } + } + } + + private var statusSection: some View { + Group { + sessionIndicator + Text("Today: \(store.formattedTodaysCost)") + } + } + + @ViewBuilder + private var sessionIndicator: some View { + if let session = store.activeSession, session.isActive { + Label("Session Active", systemImage: "dot.radiowaves.left.and.right") + .foregroundColor(.green) + } + } + + private var actionsSection: some View { + Group { + Button("Main") { WindowActions.showMainWindow() } + OpenAtLoginToggle(settingsService: settingsService) + } + } + + private var quitButton: some View { + Button("Quit") { NSApplication.shared.terminate(nil) } + } +} + +// MARK: - Menu Bar Appearance + +@MainActor +enum MenuBarAppearance { + case active + case warning + case normal + + var icon: String { + switch self { + case .active: "dollarsign.circle.fill" + case .warning: "exclamationmark.triangle.fill" + case .normal: "dollarsign.circle" + } + } + + var color: Color { + switch self { + case .active: .green + case .warning: .orange + case .normal: .primary + } + } + + static func from(store: UsageStore) -> MenuBarAppearance { + if store.hasActiveSession { return .active } + if store.isOverBudget { return .warning } + return .normal + } +} + +// MARK: - UsageStore Appearance Helpers + +extension UsageStore { + var hasActiveSession: Bool { + activeSession?.isActive == true + } + + var isOverBudget: Bool { + todaysCost > dailyCostThreshold + } +} + +// MARK: - Preview + +#if DEBUG +struct MenuBarPreview: View { + @State private var store = UsageStore() + + var body: some View { + content + .frame(height: 500) + .task { await store.loadData() } + } + + @ViewBuilder + private var content: some View { + if store.state.hasLoaded { + MenuBarContentView(settingsService: AppSettingsService()) + .environment(store) + } else { + ProgressView("Loading...") + .frame(width: MenuBarTheme.Layout.menuBarWidth, height: 200) + } + } +} + +struct MenuBarLabelPreview: View { + @State private var store = UsageStore() + + var body: some View { + content + .frame(width: 200, height: 100) + .task { await store.loadData() } + } + + @ViewBuilder + private var content: some View { + if store.state.hasLoaded { + MenuBarLabel(store: store) + .padding() + .background(Color(nsColor: .windowBackgroundColor)) + .cornerRadius(8) + } else { + ProgressView() + } + } +} + +#Preview("Menu Bar Content", traits: .sizeThatFitsLayout) { + MenuBarPreview() +} + +#Preview("Menu Bar Label", traits: .sizeThatFitsLayout) { + MenuBarLabelPreview() +} +#endif + +// MARK: - Window Actions + +public enum WindowActions { + @MainActor + public static func showMainWindow() { + let targetScreen = captureScreenAtMouseLocation() + activateApp() + findAndShowWindow(on: targetScreen) + } + + @MainActor + private static func captureScreenAtMouseLocation() -> NSScreen? { + let mouseLocation = NSEvent.mouseLocation + return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } + ?? NSScreen.main + } + + @MainActor + private static func activateApp() { + NSApp.activate(ignoringOtherApps: true) + } + + @MainActor + private static func findAndShowWindow(on targetScreen: NSScreen?) { + guard let window = NSApp.windows.first(where: { $0.title == AppMetadata.name }) else { return } + moveToActiveSpace(window) + restoreIfMinimized(window) + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { centerWindow(window, on: targetScreen) } + } + + @MainActor + private static func moveToActiveSpace(_ window: NSWindow) { + window.collectionBehavior.insert(.moveToActiveSpace) + } + + @MainActor + private static func restoreIfMinimized(_ window: NSWindow) { + if window.isMiniaturized { window.deminiaturize(nil) } + } + + @MainActor + private static func centerWindow(_ window: NSWindow, on screen: NSScreen?) { + guard let screen else { return } + let frame = screen.visibleFrame + let size = window.frame.size + let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.midY - size.height / 2) + window.setFrame(NSRect(origin: origin, size: size), display: true) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift new file mode 100644 index 0000000..c5f1e9e --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift @@ -0,0 +1,169 @@ +import SwiftUI +import Charts +import ClaudeUsageCore + +struct CostMetricsSection: View { + @Environment(UsageStore.self) private var store + + @State private var cachedTodaysCostColor: Color = MenuBarTheme.Colors.Status.normal + @State private var lastCostProgress: Double = 0 + + var body: some View { + VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { + todaysCostView + summaryStatsViewIfAvailable + } + .onChange(of: store.todaysCostProgress) { oldValue, newValue in + updateCostColorIfChanged(oldValue: oldValue, newValue: newValue) + } + .onAppear(perform: initializeCachedValues) + } + + private var summaryStatsViewIfAvailable: some View { + Group { + if let stats = store.stats { + summaryStatsView(stats) + } + } + } + + private func updateCostColorIfChanged(oldValue: Double, newValue: Double) { + guard abs(oldValue - newValue) > 0.01 else { return } + cachedTodaysCostColor = ColorService.colorForCostProgress(newValue) + lastCostProgress = newValue + } + + private func initializeCachedValues() { + cachedTodaysCostColor = ColorService.colorForCostProgress(store.todaysCostProgress) + lastCostProgress = store.todaysCostProgress + } +} + +// MARK: - Today's Cost View + +private extension CostMetricsSection { + var todaysCostView: some View { + HStack(spacing: 12) { + todaysCostLabel + Spacer() + hourlyCostChartIfAvailable + } + .padding(.vertical, MenuBarTheme.Layout.verticalPadding) + } + + var todaysCostLabel: some View { + VStack(alignment: .leading, spacing: 2) { + Text("Today") + .font(MenuBarTheme.Typography.metricTitle) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + costValueWithWarning + } + .layoutPriority(1) + } + + var costValueWithWarning: some View { + HStack(spacing: 4) { + Text(store.formattedTodaysCost) + .font(MenuBarTheme.Typography.metricValue) + .foregroundColor(cachedTodaysCostColor) + .monospacedDigit() + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + budgetExceededWarningIfNeeded + } + } + + @ViewBuilder + var budgetExceededWarningIfNeeded: some View { + if store.todaysCostProgress > 1.0 { + Image(systemName: "flame.fill") + .font(MenuBarTheme.Typography.warningIcon) + .foregroundColor(MenuBarTheme.Colors.Status.critical) + } + } + + @ViewBuilder + var hourlyCostChartIfAvailable: some View { + if !store.todayHourlyCosts.isEmpty { + HourlyCostChartSimple(hourlyData: store.todayHourlyCosts) + } + } +} + +// MARK: - Summary Stats View + +private extension CostMetricsSection { + func summaryStatsView(_ stats: UsageStats) -> some View { + HStack { + totalCostStat(stats) + Spacer() + dailyAverageStat + } + .padding(.bottom, MenuBarTheme.Layout.verticalPadding) + } + + func totalCostStat(_ stats: UsageStats) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text("Total") + .font(MenuBarTheme.Typography.summaryLabel) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + Text(stats.totalCost.asCurrency) + .font(MenuBarTheme.Typography.summaryValue) + .monospacedDigit() + } + } + + var dailyAverageStat: some View { + VStack(alignment: .trailing, spacing: 2) { + Text("7d Avg") + .font(MenuBarTheme.Typography.summaryLabel) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + Text(FormatterService.formatDailyAverage(store.averageDailyCost)) + .font(MenuBarTheme.Typography.summaryValue) + .monospacedDigit() + } + } +} + +// MARK: - Y-Axis Labels Component + +private struct YAxisLabels: View { + let maxValue: Double + + private static let labelMultipliers: [Double] = [1.0, 0.75, 0.5, 0.25, 0.0] + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + ForEach(Self.labelMultipliers, id: \.self) { multiplier in + axisLabel(value: maxValue * multiplier) + if multiplier != 0.0 { + Spacer() + } + } + } + .padding(.bottom, 12) + } + + private func axisLabel(value: Double) -> some View { + Text(CostFormat.format(value)) + .font(.system(size: 8, weight: .regular, design: .monospaced)) + .foregroundColor(.gray) + } +} + +// MARK: - Cost Format + +private enum CostFormat { + static func format(_ value: Double) -> String { + switch value { + case 0: + "$0" + case ..<1: + String(format: "$%.2f", value) + case ..<10: + String(format: "$%.1f", value) + default: + String(format: "$%.0f", value) + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift new file mode 100644 index 0000000..b46571c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift @@ -0,0 +1,31 @@ +// +// SessionMetricsSection.swift +// Live session metrics section component +// + +import SwiftUI +import ClaudeUsageCore + +struct SessionMetricsSection: View { + @Environment(UsageStore.self) private var store + + var body: some View { + VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { + if let session = store.activeSession { + // Time since session started / 5h window + MetricRow( + title: "Time", + value: FormatterService.formatTimeInterval( + Date().timeIntervalSince(session.startTime), + totalInterval: session.endTime.timeIntervalSince(session.startTime) + ), + subvalue: nil, + percentage: store.sessionTimeProgress * 100, + segments: ColorService.sessionTimeSegments(), + trendData: nil, + showWarning: false + ) + } + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift new file mode 100644 index 0000000..8a0a5da --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift @@ -0,0 +1,72 @@ +// +// UsageMetricsSection.swift +// Usage metrics section component +// + +import SwiftUI +import ClaudeUsageCore + +struct UsageMetricsSection: View { + @Environment(UsageStore.self) private var store + + var body: some View { + VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { + if store.stats != nil { + // Token usage - show raw count only (no fake percentage) + // Claude's actual rate limit is not exposed in usage data + if let session = store.activeSession { + TokenDisplay(tokens: session.tokens.total) + } + + // Burn rate + if let burnRate = store.burnRate { + burnRateView(burnRate) + } + } + } + } + + // MARK: - Burn Rate View + private func burnRateView(_ burnRate: BurnRate) -> some View { + HStack { + Label( + FormatterService.formatTokenRate(burnRate.tokensPerMinute), + systemImage: "flame.fill" + ) + .font(MenuBarTheme.Typography.burnRateLabel) + .foregroundColor(MenuBarTheme.Colors.Status.warning) + + Spacer() + + Text(FormatterService.formatCostRate(burnRate.costPerHour)) + .font(MenuBarTheme.Typography.burnRateValue) + .foregroundColor(MenuBarTheme.Colors.Status.warning) + .monospacedDigit() + } + .padding(.bottom, MenuBarTheme.Layout.verticalPadding) + } +} + +// MARK: - Token Display (no fake percentage) + +private struct TokenDisplay: View { + let tokens: Int + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Tokens") + .font(MenuBarTheme.Typography.metricTitle) + .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) + } + + Spacer() + + Text(FormatterService.formatTokenCount(tokens)) + .font(MenuBarTheme.Typography.metricValue.weight(.medium)) + .foregroundColor(MenuBarTheme.Colors.UI.primaryText) + .monospacedDigit() + } + .padding(.vertical, MenuBarTheme.Layout.verticalPadding) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift new file mode 100644 index 0000000..6b675e5 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift @@ -0,0 +1,61 @@ +// +// MenuBarStyles.swift +// Reusable button styles for the menu bar +// + +import SwiftUI + +// MARK: - Menu Button Style +@available(macOS 13.0, *) +struct MenuButtonStyle: ButtonStyle { + enum Style { + case primary, secondary + } + + let style: Style + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(MenuBarTheme.Typography.actionButton) + .foregroundColor(foregroundColor) + .padding(.horizontal, MenuBarTheme.Button.horizontalPadding) + .padding(.vertical, MenuBarTheme.Button.verticalPadding) + .background( + RoundedRectangle(cornerRadius: MenuBarTheme.Button.cornerRadius) + .fill(backgroundColor) + .overlay( + RoundedRectangle(cornerRadius: MenuBarTheme.Button.cornerRadius) + .stroke(borderColor, lineWidth: MenuBarTheme.Button.borderWidth) + ) + ) + .scaleEffect(configuration.isPressed ? MenuBarTheme.Animation.scalePressed : MenuBarTheme.Animation.scaleNormal) + .animation(MenuBarTheme.Animation.buttonPress, value: configuration.isPressed) + } + + private var foregroundColor: Color { + switch style { + case .primary: + return MenuBarTheme.Colors.UI.primaryButtonText + case .secondary: + return MenuBarTheme.Colors.UI.secondaryButtonText + } + } + + private var backgroundColor: Color { + switch style { + case .primary: + return MenuBarTheme.Colors.UI.primaryButtonBackground + case .secondary: + return MenuBarTheme.Colors.UI.secondaryButtonBackground + } + } + + private var borderColor: Color { + switch style { + case .primary: + return MenuBarTheme.Colors.UI.primaryButtonBorder + case .secondary: + return MenuBarTheme.Colors.UI.secondaryButtonBorder + } + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift new file mode 100644 index 0000000..36c4aa4 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift @@ -0,0 +1,167 @@ +// +// MenuBarTheme.swift +// Centralized theme and design constants for the menu bar +// + +import SwiftUI + +@available(macOS 13.0, *) +struct MenuBarTheme { + + // MARK: - Layout Constants + struct Layout { + // Menu Bar Width + static let menuBarWidth: CGFloat = 360 + + // Padding + static let horizontalPadding: CGFloat = 20 + static let contentHorizontalPadding: CGFloat = 12 // Reduced padding for content sections + static let verticalPadding: CGFloat = 10 + + // Spacing + static let sectionSpacing: CGFloat = 14 + static let itemSpacing: CGFloat = 8 + static let dividerVerticalPadding: CGFloat = 6 + static let actionButtonsBottomPadding: CGFloat = 14 + + // Progress Bar + static let progressBarHeight: CGFloat = 10 + static let progressBarCornerRadius: CGFloat = 4 + + // Graph + static let graphHeight: CGFloat = 45 + static let graphCornerRadius: CGFloat = 6 + static let graphWidth: CGFloat = 100 + static let largeGraphWidth: CGFloat = 200 + static let costGraphHeight: CGFloat = 50 + + // Dots + static let dataDotSize: CGFloat = 4 + static let gridLineCount: Int = 4 + } + + // MARK: - Typography + struct Typography { + // Regular Typography + static let sectionTitle = Font.system(size: 10, weight: .semibold) + static let sectionTitleKerning: CGFloat = 0.8 + + static let metricTitle = Font.system(size: 11) + static let metricValue = Font.system(size: 15, weight: .semibold, design: .rounded) + static let metricSubvalue = Font.system(size: 9) + static let summaryValue = Font.system(size: 12, weight: .medium) + static let summaryLabel = Font.system(size: 10) + + static let burnRateLabel = Font.system(size: 11) + static let burnRateValue = Font.system(size: 11, weight: .medium) + + static let actionButton = Font.system(size: 11, weight: .medium) + + static let badgeText = Font.system(size: 9, weight: .medium) + static let sectionIcon = Font.system(size: 12, weight: .medium) + static let warningIcon = Font.system(size: 11) + static let overflowIcon = Font.system(size: 8) + } + + // MARK: - Colors + struct Colors { + // Progress Bar Segments + struct ProgressSegments { + static let green = Color.green + static let orange = Color.orange + static let red = Color.red + static let blue = Color.blue + static let purple = Color.purple + } + + // Status Colors + struct Status { + static let active = Color.green + static let warning = Color.orange + static let critical = Color.red + static let normal = Color.blue + } + + // UI Colors + struct UI { + static let background = Color(NSColor.controlBackgroundColor) + static let secondaryText = Color.secondary + static let primaryText = Color.primary + + static let trackBackground = Color.gray.opacity(0.1) + static let trackBorder = Color.gray.opacity(0.2) + static let sectionBackground = Color.gray.opacity(0.05) + + static let primaryButtonBackground = Color.blue.opacity(0.1) + static let primaryButtonBorder = Color.blue.opacity(0.3) + static let primaryButtonText = Color.blue + + static let secondaryButtonBackground = Color.gray.opacity(0.1) + static let secondaryButtonBorder = Color.gray.opacity(0.2) + static let secondaryButtonText = Color.primary + + static let gridLines = Color.gray.opacity(0.1) + } + + // Section Colors + struct Sections { + static let liveSession = Color.green + static let usage = Color.blue + static let system = Color.purple + static let cost = Color.purple + } + } + + // MARK: - Performance Thresholds + struct Thresholds { + struct Percentage { + static let low: Double = 60.0 + static let medium: Double = 80.0 + static let high: Double = 100.0 + } + + struct Cost { + static let normal: Double = 0.6 + static let warning: Double = 0.8 + static let critical: Double = 1.0 + } + + struct Sessions { + static let timeSegments = (low: 0.7, medium: 0.9, max: 1.5) + static let tokenSegments = (low: 0.6, medium: 0.85, max: 1.5) + } + } + + // MARK: - Animation + struct Animation { + static let buttonPress = SwiftUI.Animation.easeInOut(duration: 0.1) + static let scalePressed: CGFloat = 0.95 + static let scaleNormal: CGFloat = 1.0 + } + + // MARK: - Button Styles + struct Button { + static let horizontalPadding: CGFloat = 16 + static let verticalPadding: CGFloat = 6 + static let cornerRadius: CGFloat = 6 + static let borderWidth: CGFloat = 1 + } + + // MARK: - Badge + struct Badge { + static let horizontalPadding: CGFloat = 6 + static let verticalPadding: CGFloat = 2 + static let cornerRadius: CGFloat = 4 + } + + // MARK: - Graph Properties + struct Graph { + static let lineWidth: CGFloat = 2 + static let strokeWidth: CGFloat = 0.5 + static let minimumRange: Double = 0.01 + static let areaGradientTopOpacity: Double = 0.3 + static let areaGradientBottomOpacity: Double = 0.05 + static let progressGradientStartOpacity: Double = 0.8 + static let progressGradientEndOpacity: Double = 1.0 + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift new file mode 100644 index 0000000..7cec52a --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift @@ -0,0 +1,151 @@ +// +// AppSettingsService.swift +// Centralized settings management with proper architecture +// + +import Foundation +import ServiceManagement +import SwiftUI +import Observation +import OSLog + +private let logger = Logger(subsystem: "com.claudecodeusage", category: "AppSettings") + +// MARK: - Protocol + +@MainActor +protocol AppSettingsServiceProtocol: AnyObject { + var isOpenAtLoginEnabled: Bool { get } + func setOpenAtLogin(_ enabled: Bool) async -> Result + func showAboutPanel() +} + +// MARK: - Main Implementation + +@Observable +@MainActor +public final class AppSettingsService: AppSettingsServiceProtocol { + public private(set) var isOpenAtLoginEnabled: Bool = false + + public var appName: String { AppMetadata.name } + + public init() { + refreshLoginStatus() + } + + // MARK: - Public API (High-Level Intent) + + public func setOpenAtLogin(_ enabled: Bool) async -> Result { + guard #available(macOS 13.0, *) else { + return .failure(.unsupportedOS) + } + + guard needsChange(to: enabled) else { + return .success(()) + } + + do { + try await updateServiceRegistration(enabled: enabled) + refreshLoginStatus() + return .success(()) + } catch { + logger.error("Failed to set launch at login: \(error.localizedDescription)") + return .failure(.serviceManagementFailed(error)) + } + } + + public func showAboutPanel() { + NSApp.orderFrontStandardAboutPanel(options: aboutPanelOptions) + } + + // MARK: - Private Helpers (Infrastructure) + + @available(macOS 13.0, *) + private func needsChange(to enabled: Bool) -> Bool { + let currentlyEnabled = SMAppService.mainApp.status == .enabled + return currentlyEnabled != enabled + } + + @available(macOS 13.0, *) + private func updateServiceRegistration(enabled: Bool) async throws { + if enabled { + try SMAppService.mainApp.register() + } else { + try await SMAppService.mainApp.unregister() + } + } + + private func refreshLoginStatus() { + guard #available(macOS 13.0, *) else { + isOpenAtLoginEnabled = false + return + } + isOpenAtLoginEnabled = SMAppService.mainApp.status == .enabled + } + + private var aboutPanelOptions: [NSApplication.AboutPanelOptionKey: Any] { + [ + .applicationName: AppMetadata.name, + .applicationVersion: AppMetadata.version, + .credits: NSAttributedString( + string: AppMetadata.credits, + attributes: [.font: NSFont.systemFont(ofSize: 11)] + ) + ] + } +} + +// MARK: - Mock for Testing + +#if DEBUG +@Observable +@MainActor +final class MockAppSettingsService: AppSettingsServiceProtocol { + private(set) var isOpenAtLoginEnabled: Bool = false + + func setOpenAtLogin(_ enabled: Bool) async -> Result { + isOpenAtLoginEnabled = enabled + return .success(()) + } + + func showAboutPanel() { + logger.debug("About panel shown") + } +} +#endif + +// MARK: - Supporting Types + +public enum AppSettingsError: LocalizedError { + case serviceManagementFailed(Error) + case permissionDenied + case unsupportedOS + + public var errorDescription: String? { + switch self { + case .serviceManagementFailed(let error): + return "Failed to update launch settings: \(error.localizedDescription)" + case .permissionDenied: + return "Permission denied. Please check System Settings > Login Items." + case .unsupportedOS: + return "Open at Login requires macOS 13.0 or later" + } + } + + public var recoverySuggestion: String? { + switch self { + case .serviceManagementFailed: + return "Try again or check System Settings > Login Items" + case .permissionDenied: + return "Grant permission in System Settings" + case .unsupportedOS: + return "Update to macOS 13.0 or later" + } + } +} + +public enum AppMetadata { + public static let name = "Claude Usage" + public static let version = "1.0.0" + public static let credits = "Claude Code Usage Tracking" +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift new file mode 100644 index 0000000..c1ce961 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift @@ -0,0 +1,96 @@ +// +// OpenAtLoginToggle.swift +// Menu bar toggle for Open at Login setting +// + +import SwiftUI + +public struct OpenAtLoginToggle: View { + let settingsService: AppSettingsService + @State private var isHovered = false + + public init(settingsService: AppSettingsService) { + self.settingsService = settingsService + } + + public var body: some View { + HStack(spacing: Layout.spacing) { + checkboxIcon + labelText + Spacer() + } + .padding(.vertical, Layout.verticalPadding) + .padding(.horizontal, Layout.horizontalPadding) + .background(hoverBackground) + .onHover { isHovered = $0 } + .onTapGesture(perform: toggleSetting) + .help("Launch \(AppMetadata.name) automatically when you log in") + } +} + +// MARK: - Subviews + +private extension OpenAtLoginToggle { + var checkboxIcon: some View { + Image(systemName: checkboxIconName) + .foregroundColor(checkboxColor) + .font(.system(size: Layout.iconSize)) + .animation(Layout.hoverAnimation, value: isEnabled) + .animation(Layout.hoverAnimation, value: isHovered) + } + + var labelText: some View { + Text("Open at Login") + .font(MenuBarTheme.Typography.actionButton) + .foregroundColor(labelColor) + } + + var hoverBackground: some View { + RoundedRectangle(cornerRadius: Layout.cornerRadius) + .fill(isHovered ? Color.gray.opacity(Layout.hoverOpacity) : .clear) + } +} + +// MARK: - Computed State + +private extension OpenAtLoginToggle { + var isEnabled: Bool { + settingsService.isOpenAtLoginEnabled + } + + var checkboxIconName: String { + isEnabled ? "checkmark.square.fill" : "square" + } + + var checkboxColor: Color { + isEnabled ? .accentColor : (isHovered ? .primary : .secondary) + } + + var labelColor: Color { + isHovered ? .primary : .secondary + } +} + +// MARK: - Actions + +private extension OpenAtLoginToggle { + func toggleSetting() { + Task { + _ = await settingsService.setOpenAtLogin(!isEnabled) + } + } +} + +// MARK: - Layout Constants + +private extension OpenAtLoginToggle { + enum Layout { + static let spacing: CGFloat = 8 + static let iconSize: CGFloat = 12 + static let verticalPadding: CGFloat = 6 + static let horizontalPadding: CGFloat = 8 + static let cornerRadius: CGFloat = 4 + static let hoverOpacity: Double = 0.1 + static let hoverAnimation = Animation.easeInOut(duration: 0.1) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift new file mode 100644 index 0000000..70c5510 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift @@ -0,0 +1,118 @@ +// +// HourlyChartModels.swift +// Data models for TDD hourly chart functionality +// + +import Foundation +import ClaudeUsageCore + +// MARK: - TDD Hourly Chart Data Models + +/// Represents a single bar in the TDD hourly chart +public struct HourlyBar: Identifiable, Equatable { + /// Unique identifier based on hour + public var id: String { "hour-\(hour)" } + + /// Hour of the day (0-23) + public let hour: Int + + /// Total cost for this hour + public let cost: Double + + /// Number of entries for this hour + public let entryCount: Int + + /// Pre-formatted cost string for display + public var formattedCost: String { + cost.asCurrency + } + + /// Whether this hour has no usage + public var isEmpty: Bool { + cost == 0 && entryCount == 0 + } + + public init(hour: Int, cost: Double, entryCount: Int) { + self.hour = hour + self.cost = cost + self.entryCount = entryCount + } +} + +/// Complete dataset for TDD hourly chart visualization +public struct HourlyChartDataset: Equatable { + /// Array of 24 bars representing each hour + public let bars: [HourlyBar] + + /// Total cost across all hours + public let totalCost: Double + + /// Peak usage hour (hour with highest cost) + public let peakHour: Int? + + /// Peak cost value + public let peakCost: Double + + /// Whether an error occurred during data generation + public let hasError: Bool + + /// Error message if hasError is true + public let errorMessage: String + + public init( + bars: [HourlyBar], + totalCost: Double = 0, + peakHour: Int? = nil, + peakCost: Double = 0, + hasError: Bool = false, + errorMessage: String = "" + ) { + self.bars = bars + self.totalCost = totalCost == 0 ? bars.reduce(0) { $0 + $1.cost } : totalCost + + // Calculate peak hour and cost if not provided + if let peakHour = peakHour { + self.peakHour = peakHour + self.peakCost = peakCost + } else { + let maxBar = bars.max { $0.cost < $1.cost } + self.peakHour = maxBar?.cost ?? 0 > 0 ? maxBar?.hour : nil + self.peakCost = maxBar?.cost ?? 0 + } + + self.hasError = hasError + self.errorMessage = errorMessage + } + + /// Create an error state chart data + public static func error(_ message: String) -> HourlyChartDataset { + // Create empty bars for all 24 hours + let emptyBars = (0..<24).map { HourlyBar(hour: $0, cost: 0, entryCount: 0) } + return HourlyChartDataset( + bars: emptyBars, + totalCost: 0, + peakHour: nil, + peakCost: 0, + hasError: true, + errorMessage: message + ) + } +} + +/// Tooltip data for hourly chart bars +public struct HourlyTooltip: Equatable { + /// Time range string (e.g., "2:00 PM - 3:00 PM") + public let timeRange: String + + /// Formatted cost string (e.g., "$25.50" or "No usage") + public let cost: String + + /// Entry count string (e.g., "3 requests" or "No requests") + public let entryCount: String + + public init(timeRange: String, cost: String, entryCount: String) { + self.timeRange = timeRange + self.cost = cost + self.entryCount = entryCount + } +} \ No newline at end of file diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift new file mode 100644 index 0000000..636e471 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift @@ -0,0 +1,250 @@ +// +// HourlyCostChartSimple.swift +// Simplified Swift Charts implementation for hourly costs +// + +import SwiftUI +import Charts +import ClaudeUsageCore + +// MARK: - Simple Hourly Cost Chart + +struct HourlyCostChartSimple: View { + let hourlyData: [Double] + var maxScale: Double? = nil // Optional shared scale for comparing multiple charts + @State private var selectedHour: Int? = nil + + var body: some View { + GeometryReader { geometry in + let chartWidth = geometry.size.width - 25 + VStack(spacing: 4) { + headerRow + chart + } + .overlay(alignment: .top) { + tooltipOverlay(chartWidth: chartWidth, totalWidth: geometry.size.width) + } + } + .frame(height: 76) // 16 (header) + 60 (chart) + } +} + +// MARK: - Computed Properties + +private extension HourlyCostChartSimple { + var maxValue: Double { + maxScale ?? hourlyData.max() ?? 1.0 + } + + var currentHour: Int { + Calendar.current.component(.hour, from: Date()) + } + + var yAxisScale: YAxisScale { + YAxisScale(maxValue: maxValue) + } + + var headerRow: some View { + Text("Hourly") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + var chart: some View { + costChart + .frame(height: 60) + .padding(.trailing, 25) + } + + var costChart: some View { + Chart(Array(hourlyData.enumerated()), id: \.offset) { hour, cost in + BarMark( + x: .value("Hour", hour), + y: .value("Cost", cost) + ) + .foregroundStyle(CostIntensity(cost: cost, maxValue: maxValue).color) + .opacity(hour <= currentHour ? 1.0 : 0.3) + + if let selectedHour, selectedHour == hour { + selectionIndicator(for: hour) + } + } + .chartXSelection(value: $selectedHour) + .chartXAxis { xAxisMarks } + .chartYAxis { yAxisMarks } + .chartYScale(domain: 0...yAxisScale.roundedMax) + .chartXScale(range: .plotDimension(padding: 8)) + } + + var xAxisMarks: some AxisContent { + AxisMarks(values: [0, 6, 12, 18, 23]) { value in + AxisValueLabel { + if let hour = value.as(Int.self) { + Text("\(hour)") + .font(.system(size: 8)) + .foregroundColor(.secondary) + } + } + } + } + + var yAxisMarks: some AxisContent { + AxisMarks(position: .trailing, values: yAxisScale.tickValues) { value in + AxisValueLabel { + if let cost = value.as(Double.self) { + Text(CostAxisFormat(cost).formatted) + .font(.system(size: 8, design: .monospaced)) + .foregroundColor(.secondary) + } + } + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.25)) + .foregroundStyle(.tertiary) + } + } + + func selectionIndicator(for hour: Int) -> some ChartContent { + RuleMark(x: .value("Hour", hour)) + .foregroundStyle(.primary.opacity(0.3)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 2])) + } + + @ViewBuilder + func tooltipOverlay(chartWidth: CGFloat, totalWidth: CGFloat) -> some View { + if let selectedHour, + selectedHour >= 0 && selectedHour < hourlyData.count { + HourlyTooltipView( + hour: selectedHour, + cost: hourlyData[selectedHour], + isCompact: true + ) + .offset(x: tooltipXPosition(for: selectedHour, chartWidth: chartWidth, totalWidth: totalWidth)) + .allowsHitTesting(false) + } + } + + func tooltipXPosition(for hour: Int, chartWidth: CGFloat, totalWidth: CGFloat) -> CGFloat { + // With .top alignment, offset is relative to VStack center + let barWidth = chartWidth / 24 + let barCenter = (CGFloat(hour) + 0.5) * barWidth + let vstackCenter = totalWidth / 2 + return barCenter - vstackCenter + } +} + +// MARK: - Preview + +struct HourlyCostChartSimple_Previews: PreviewProvider { + static var previews: some View { + VStack { + HourlyCostChartSimple(hourlyData: sampleHourlyData) + .padding() + Spacer() + } + .frame(width: 300, height: 200) + .background(Color(.windowBackgroundColor)) + } + + static var sampleHourlyData: [Double] { + [ + 0, 0, 0, 2.5, 5.0, 3.2, 8.5, 12.0, 15.5, 10.0, + 8.5, 7.2, 6.0, 9.5, 11.0, 8.0, 5.5, 3.0, 2.0, 1.5, + 0.5, 0, 0, 0 + ] + } +} + +// MARK: - Supporting Types + +// MARK: Cost Intensity + +enum CostIntensity { + case zero + case low + case medium + case high + case peak + + init(cost: Double, maxValue: Double) { + guard cost > 0 else { + self = .zero + return + } + let intensity = min(cost / max(maxValue, 1.0), 1.0) + switch intensity { + case 0.8...: self = .peak + case 0.5...: self = .high + case 0.2...: self = .medium + default: self = .low + } + } + + var color: Color { + switch self { + case .zero: .gray.opacity(0.2) + case .low: .mint + case .medium: .teal + case .high: .cyan + case .peak: .blue + } + } +} + +// MARK: Y-Axis Scale + +enum YAxisScale { + case small(max: Double) + case medium(max: Double) + case large(max: Double) + case extraLarge(max: Double) + + init(maxValue: Double) { + switch maxValue { + case ...10: self = .small(max: maxValue) + case ...50: self = .medium(max: maxValue) + case ...100: self = .large(max: maxValue) + default: self = .extraLarge(max: maxValue) + } + } + + var roundedMax: Double { + switch self { + case .small(let max): ceil(max) + case .medium(let max): ceil(max / 10) * 10 + case .large(let max): ceil(max / 20) * 20 + case .extraLarge(let max): ceil(max / 50) * 50 + } + } + + var tickValues: [Double] { + let max = roundedMax + switch self { + case .small: return [0, max / 2, max] + default: return [0, max / 3, max * 2 / 3, max] + } + } +} + +// MARK: Cost Formatting + +enum CostAxisFormat { + case zero + case decimal(Double) + case whole(Double) + + init(_ value: Double) { + switch value { + case 0: self = .zero + case ..<10: self = .decimal(value) + default: self = .whole(value) + } + } + + var formatted: String { + switch self { + case .zero: "$0" + case .decimal(let value): String(format: "$%.1f", value) + case .whole(let value): String(format: "$%.0f", value) + } + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift new file mode 100644 index 0000000..f372687 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift @@ -0,0 +1,75 @@ +// +// HourlyTooltipViews.swift +// Tooltip views for hourly cost charts +// + +import SwiftUI + +// MARK: - Simple Tooltip View + +struct HourlyTooltipView: View { + let hour: Int + let cost: Double + let isCompact: Bool + + private var formattedHour: String { + let formatter = DateFormatter() + formatter.dateFormat = isCompact ? "HH:mm" : "h:mm a" + + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.year, .month, .day], from: now) + var hourComponents = components + hourComponents.hour = hour + hourComponents.minute = 0 + + if let date = calendar.date(from: hourComponents) { + return formatter.string(from: date) + } + return String(format: "%02d:00", hour) + } + + private var formattedCost: String { + if cost == 0 { + return "$0.00" + } + return cost.asCurrency + } + + var body: some View { + VStack(spacing: 2) { + Text(formattedHour) + .font(.system(size: isCompact ? 9 : 10, weight: .semibold, design: .monospaced)) + .foregroundColor(.primary) + + Text(formattedCost) + .font(.system(size: isCompact ? 8 : 9, weight: .medium, design: .monospaced)) + .foregroundColor(cost > 0 ? .blue : .secondary) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(.regularMaterial) + .stroke(.tertiary, lineWidth: 0.5) + .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("Hour \(formattedHour), cost \(formattedCost)") + .transition(.scale(scale: 0.8).combined(with: .opacity)) + .animation(.easeInOut(duration: 0.15), value: hour) + } +} + +// MARK: - Previews + +struct HourlyTooltipView_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: 20) { + HourlyTooltipView(hour: 14, cost: 12.50, isCompact: false) + HourlyTooltipView(hour: 10, cost: 0, isCompact: true) + } + .padding() + .background(Color(.windowBackgroundColor)) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift new file mode 100644 index 0000000..f1b1f5d --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift @@ -0,0 +1,123 @@ +// +// ClockProtocol.swift +// Protocol for time abstraction to improve testability +// + +import Foundation + +// MARK: - Clock Protocol + +/// Protocol for abstracting time operations, enabling testable time-dependent code +@MainActor +protocol ClockProtocol: Sendable { + /// Current date and time + var now: Date { get } + + /// Sleep for the specified duration + func sleep(for duration: Duration) async throws + + /// Sleep for the specified time interval (legacy support) + func sleep(for seconds: TimeInterval) async throws +} + +// MARK: - Default Implementations + +extension ClockProtocol { + /// Format date as string + func format(date: Date, format: String) -> String { + DateFormatting.formatted(date, using: format) + } + + /// Calculate time until next occurrence of specified time + func timeUntil(hour: Int, minute: Int, second: Int) -> TimeInterval { + TimeCalculation.intervalUntilNextOccurrence( + of: (hour: hour, minute: minute, second: second), + from: now + ) + } +} + +// MARK: - Clock Provider + +/// Manages clock instance for dependency injection +@MainActor +struct ClockProvider { + private static var _current: ClockProtocol? + + /// Current clock instance (defaults to SystemClock in production) + static var current: ClockProtocol { + get { _current ?? SystemClock() } + set { _current = newValue } + } + + /// Reset to default (SystemClock) + static func reset() { + _current = nil + } + + /// Use test clock for testing + static func useTestClock(_ clock: TestClock) { + _current = clock + } +} + +// MARK: - Pure Date Formatting + +private enum DateFormatting { + static func formatted(_ date: Date, using format: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = format + return formatter.string(from: date) + } +} + +// MARK: - Pure Time Calculation + +private enum TimeCalculation { + static func intervalUntilNextOccurrence( + of time: (hour: Int, minute: Int, second: Int), + from referenceDate: Date + ) -> TimeInterval { + let calendar = Calendar.current + + guard let targetTime = buildTargetTime(time, on: referenceDate, using: calendar) else { + return 0 + } + + return adjustedInterval(from: referenceDate, to: targetTime, using: calendar) + } + + private static func buildTargetTime( + _ time: (hour: Int, minute: Int, second: Int), + on date: Date, + using calendar: Calendar + ) -> Date? { + var components = calendar.dateComponents([.year, .month, .day], from: date) + components.hour = time.hour + components.minute = time.minute + components.second = time.second + return calendar.date(from: components) + } + + private static func adjustedInterval( + from referenceDate: Date, + to targetTime: Date, + using calendar: Calendar + ) -> TimeInterval { + if targetTime <= referenceDate { + return tomorrowInterval(from: referenceDate, to: targetTime, using: calendar) + } + return targetTime.timeIntervalSince(referenceDate) + } + + private static func tomorrowInterval( + from referenceDate: Date, + to targetTime: Date, + using calendar: Calendar + ) -> TimeInterval { + guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: targetTime) else { + return 0 + } + return tomorrow.timeIntervalSince(referenceDate) + } +} diff --git a/Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift similarity index 100% rename from Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift rename to Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift new file mode 100644 index 0000000..c99be44 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift @@ -0,0 +1,104 @@ +// +// TestClock.swift +// Controllable clock for testing time-dependent code +// + +import Foundation + +// MARK: - Test Clock + +/// Controllable clock for testing time-dependent code +@MainActor +final class TestClock: ClockProtocol { + private(set) var currentTime: Date + private var sleepRecords: [(duration: TimeInterval, timestamp: Date)] = [] + + init(startTime: Date = Date()) { + self.currentTime = startTime + } + + var now: Date { + currentTime + } + + func sleep(for duration: Duration) async throws { + try await sleep(for: duration.asTimeInterval) + } + + func sleep(for seconds: TimeInterval) async throws { + sleepRecords.append((duration: seconds, timestamp: currentTime)) + advance(by: seconds) + await Task.yield() + } + + // MARK: - Test Control Methods + + /// Advance time by the specified interval + func advance(by interval: TimeInterval) { + currentTime = currentTime.addingTimeInterval(interval) + } + + /// Set the current time to a specific date + func setTime(to date: Date) { + currentTime = date + } + + /// Advance to just before midnight + func advanceToAlmostMidnight() { + if let almostMidnight = TimeBuilder.almostMidnight(on: currentTime) { + currentTime = almostMidnight + } + } + + /// Advance to the next day + func advanceToNextDay() { + if let nextDayStart = TimeBuilder.startOfNextDay(after: currentTime) { + currentTime = nextDayStart + } + } + + /// Get all sleep records for verification + var sleepHistory: [(duration: TimeInterval, timestamp: Date)] { + sleepRecords + } + + /// Clear sleep history + func clearHistory() { + sleepRecords.removeAll() + } +} + +// MARK: - Duration Extension + +private extension Duration { + var asTimeInterval: TimeInterval { + let seconds = components.seconds + let attoseconds = components.attoseconds + return Double(seconds) + Double(attoseconds) / 1_000_000_000_000_000_000 + } +} + +// MARK: - Time Builder + +private enum TimeBuilder { + static func almostMidnight(on date: Date) -> Date? { + let calendar = Calendar.current + var components = calendar.dateComponents([.year, .month, .day], from: date) + components.hour = 23 + components.minute = 59 + components.second = 59 + return calendar.date(from: components) + } + + static func startOfNextDay(after date: Date) -> Date? { + let calendar = Calendar.current + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { + return nil + } + var components = calendar.dateComponents([.year, .month, .day], from: nextDay) + components.hour = 0 + components.minute = 0 + components.second = 1 + return calendar.date(from: components) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift new file mode 100644 index 0000000..17c3d50 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift @@ -0,0 +1,51 @@ +// +// AppConfiguration.swift +// App-wide configuration and configuration service +// + +import Foundation + +// MARK: - Home Directory Helper + +/// Returns the real user home directory, even in sandboxed apps. +/// In sandboxed apps, NSHomeDirectory() returns the container path, +/// but we need the actual user home to access ~/.claude +private func realHomeDirectory() -> String { + guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } + return String(cString: pw.pointee.pw_dir) +} + +// MARK: - Configuration + +public struct AppConfiguration: Sendable { + let basePath: String + let refreshInterval: TimeInterval + let sessionDurationHours: Double + let dailyCostThreshold: Double + + static let `default` = AppConfiguration( + basePath: realHomeDirectory() + "/.claude", + refreshInterval: 30.0, + sessionDurationHours: 5.0, + dailyCostThreshold: 10.0 + ) + + static func load() -> AppConfiguration { + // Future: Load from UserDefaults or config file + return .default + } +} + +// MARK: - Configuration Service + +protocol ConfigurationService { + var configuration: AppConfiguration { get } +} + +final class DefaultConfigurationService: ConfigurationService { + let configuration: AppConfiguration + + init() { + self.configuration = AppConfiguration.load() + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift new file mode 100644 index 0000000..05ac0d9 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift @@ -0,0 +1,176 @@ +// +// LoadTrace.swift +// Event-driven load operation tracing +// + +import Foundation +import OSLog + +// MARK: - Trace Collector + +actor LoadTrace { + static let shared = LoadTrace() + + private let logger = Logger(subsystem: "com.claudecodeusage", category: "DataFlow") + + private var loadStartTime: Date? + private var phaseStartTimes: [LoadPhase: Date] = [:] + private var phaseDurations: [LoadPhase: TimeInterval] = [:] + + // Session monitor state + private var sessionFound: Bool? + private var sessionCached: Bool = false + private var sessionDuration: TimeInterval = 0 + private var tokenLimit: Int? + private var historySkipped: Bool = false + + func start() -> UUID { + let id = UUID() + loadStartTime = Date() + resetState() + return id + } + + func phaseStart(_ phase: LoadPhase) { + phaseStartTimes[phase] = Date() + } + + func phaseComplete(_ phase: LoadPhase) { + if let start = phaseStartTimes[phase] { + phaseDurations[phase] = Date().timeIntervalSince(start) + } + } + + func recordSession(found: Bool, cached: Bool, duration: TimeInterval, tokenLimit: Int?) { + sessionFound = found + sessionCached = cached + sessionDuration = duration + self.tokenLimit = tokenLimit + } + + func skipHistory() { + historySkipped = true + } + + func complete() { + guard let startTime = loadStartTime else { return } + let duration = Date().timeIntervalSince(startTime) + printSummary(duration: duration) + resetState() + } + + // MARK: - State Management + + private func resetState() { + phaseStartTimes = [:] + phaseDurations = [:] + sessionFound = nil + sessionCached = false + sessionDuration = 0 + tokenLimit = nil + historySkipped = false + } + + // MARK: - Output + + private func printSummary(duration: TimeInterval) { + let output = buildSummary(duration: duration) + logOutput(output, isSlow: duration > Threshold.slowLoad) + } + + private func buildSummary(duration: TimeInterval) -> String { + [ + headerLine, + todayPhaseLine, + sessionLine, + historyPhaseLine, + footerLine(duration: duration) + ] + .compactMap { $0 } + .joined(separator: "\n") + } + + private var headerLine: String { + "┌─ Data Load " + String(repeating: "─", count: 40) + } + + private var todayPhaseLine: String { + let duration = phaseDurations[.today].map { " (\(formatDuration($0)))" } ?? "" + return "│ Phase 1: Today\(duration)" + } + + private var sessionLine: String? { + sessionFound.map { found in + let status = found ? "active" : "none" + let timing = sessionCached ? "cached" : formatDuration(sessionDuration) + let limitInfo = tokenLimit.map { ", limit: \(formatNumber($0))" } ?? "" + return "│ Session: \(status) [\(timing)]\(limitInfo)" + } + } + + private var historyPhaseLine: String { + if historySkipped { + return "│ Phase 2: History (skipped - same day)" + } + let duration = phaseDurations[.history].map { " (\(formatDuration($0)))" } ?? "" + return "│ Phase 2: History\(duration)" + } + + private func footerLine(duration: TimeInterval) -> String { + let status = duration > Threshold.slowLoad ? " [slow]" : "" + return "└─ Total: \(formatDuration(duration))\(status) " + String(repeating: "─", count: 28) + } + + private func logOutput(_ output: String, isSlow: Bool) { + if isSlow { + logger.warning("\(output)") + } else { + logger.info("\(output)") + } + } + + // MARK: - Formatting + + private func formatDuration(_ seconds: TimeInterval) -> String { + DurationFormatter.format(seconds) + } + + private func formatNumber(_ n: Int) -> String { + NumberFormat.decimal(n) + } +} + +// MARK: - Supporting Types + +enum LoadPhase: String { + case today = "Today" + case history = "History" +} + +private enum Threshold { + static let slowLoad: TimeInterval = 2.0 +} + +// MARK: - Pure Formatters + +private enum DurationFormatter { + static func format(_ seconds: TimeInterval) -> String { + switch seconds { + case ..<0.01: "<10ms" + case ..<1.0: String(format: "%.0fms", seconds * 1000) + default: String(format: "%.2fs", seconds) + } + } +} + +private enum NumberFormat { + private static let decimalFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter + }() + + static func decimal(_ n: Int) -> String { + decimalFormatter.string(from: NSNumber(value: n)) ?? "\(n)" + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift new file mode 100644 index 0000000..1f965e3 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift @@ -0,0 +1,76 @@ +// +// SessionMonitorService.swift +// Service for monitoring active Claude sessions +// + +import Foundation +import ClaudeUsageCore +import ClaudeUsageData + +// MARK: - Protocol + +protocol SessionMonitorService: Sendable { + func getActiveSession() async -> SessionBlock? + func getBurnRate() async -> BurnRate? + func getAutoTokenLimit() async -> Int? +} + +// MARK: - Default Implementation + +actor DefaultSessionMonitorService: SessionMonitorService { + private let monitor: SessionMonitor + private var cachedSession: (session: SessionBlock?, timestamp: Date)? + + init(configuration: AppConfiguration) { + self.monitor = SessionMonitor( + basePath: configuration.basePath, + sessionDurationHours: configuration.sessionDurationHours + ) + } + + func getActiveSession() async -> SessionBlock? { + if let cached = cachedSession, isCacheValid(timestamp: cached.timestamp) { + return cached.session + } + + let session = await monitor.getActiveSession() + cachedSession = (session, Date()) + return session + } + + func getBurnRate() async -> BurnRate? { + await getActiveSession()?.burnRate + } + + func getAutoTokenLimit() async -> Int? { + // Derive from session to avoid redundant monitor call + // (SessionBlock.tokenLimit is populated by getActiveSession) + await getActiveSession()?.tokenLimit + } +} + +// MARK: - Supporting Types + +private enum CacheConfig { + static let ttl: TimeInterval = 2.0 +} + +// MARK: - Pure Functions + +private func isCacheValid(timestamp: Date, ttl: TimeInterval = CacheConfig.ttl) -> Bool { + Date().timeIntervalSince(timestamp) < ttl +} + +// MARK: - Mock for Testing + +#if DEBUG +final class MockSessionMonitorService: SessionMonitorService, @unchecked Sendable { + var mockSession: SessionBlock? + var mockBurnRate: BurnRate? + var mockTokenLimit: Int? + + func getActiveSession() async -> SessionBlock? { mockSession } + func getBurnRate() async -> BurnRate? { mockBurnRate } + func getAutoTokenLimit() async -> Int? { mockTokenLimit } +} +#endif diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift new file mode 100644 index 0000000..2810e10 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift @@ -0,0 +1,142 @@ +// +// UsageDataLoader.swift +// Orchestrates data loading from repository and session services +// + +import Foundation +import ClaudeUsageCore + +// MARK: - UsageDataLoader + +actor UsageDataLoader { + private let repository: any UsageDataSource + private let sessionMonitorService: SessionMonitorService + + init(repository: any UsageDataSource, sessionMonitorService: SessionMonitorService) { + self.repository = repository + self.sessionMonitorService = sessionMonitorService + } + + func loadToday() async throws -> TodayLoadResult { + try await tracePhase(.today) { + try await fetchTodayData() + } + } + + func loadHistory() async throws -> FullLoadResult { + try await tracePhase(.history) { + FullLoadResult(fullStats: try await repository.getUsageStats()) + } + } + + func loadAll() async throws -> UsageLoadResult { + let today = try await loadToday() + let history = try await loadHistory() + return combineResults(today: today, history: history) + } +} + +// MARK: - Data Fetching + +private extension UsageDataLoader { + func fetchTodayData() async throws -> TodayLoadResult { + async let entriesTask = repository.getTodayEntries() + async let sessionTask = fetchSessionWithTracing() + + let entries = try await entriesTask + let (session, burnRate) = await sessionTask + + return buildTodayResult(entries: entries, session: session, burnRate: burnRate) + } + + func fetchSessionWithTracing() async -> (SessionBlock?, BurnRate?) { + let (session, timing) = await timed { await sessionMonitorService.getActiveSession() } + await recordSessionTrace(session: session, timing: timing) + return (session, session?.burnRate) + } +} + +// MARK: - Result Building + +private extension UsageDataLoader { + func buildTodayResult( + entries: [UsageEntry], + session: SessionBlock?, + burnRate: BurnRate? + ) -> TodayLoadResult { + TodayLoadResult( + todayEntries: entries, + todayStats: UsageAggregator.aggregate(entries), + session: session, + burnRate: burnRate, + autoTokenLimit: session?.tokenLimit + ) + } + + func combineResults(today: TodayLoadResult, history: FullLoadResult) -> UsageLoadResult { + UsageLoadResult( + todayEntries: today.todayEntries, + todayStats: today.todayStats, + fullStats: history.fullStats, + session: today.session, + burnRate: today.burnRate, + autoTokenLimit: today.autoTokenLimit + ) + } +} + +// MARK: - Tracing Infrastructure + +private extension UsageDataLoader { + private enum TracingThreshold { + static let cachedResponseTime: TimeInterval = 0.05 + } + + func tracePhase(_ phase: LoadPhase, operation: () async throws -> T) async rethrows -> T { + await LoadTrace.shared.phaseStart(phase) + let result = try await operation() + await LoadTrace.shared.phaseComplete(phase) + return result + } + + func recordSessionTrace(session: SessionBlock?, timing: TimeInterval) async { + await LoadTrace.shared.recordSession( + found: session != nil, + cached: timing < TracingThreshold.cachedResponseTime, + duration: timing, + tokenLimit: session?.tokenLimit + ) + } + + func timed(_ operation: () async -> T) async -> (T, TimeInterval) { + let start = Date() + let result = await operation() + return (result, Date().timeIntervalSince(start)) + } +} + +// MARK: - Supporting Types + +/// Fast result from Phase 1 - today's data only +struct TodayLoadResult { + let todayEntries: [UsageEntry] + let todayStats: UsageStats + let session: SessionBlock? + let burnRate: BurnRate? + let autoTokenLimit: Int? +} + +/// Complete result including historical data +struct FullLoadResult { + let fullStats: UsageStats +} + +/// Combined result for backward compatibility +struct UsageLoadResult { + let todayEntries: [UsageEntry] + let todayStats: UsageStats + let fullStats: UsageStats + let session: SessionBlock? + let burnRate: BurnRate? + let autoTokenLimit: Int? +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift new file mode 100644 index 0000000..075130c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift @@ -0,0 +1,193 @@ +// +// RefreshCoordinator.swift +// Manages refresh via file monitoring, lifecycle events, and day change detection +// + +import Foundation +import AppKit +import ClaudeUsageData + +// MARK: - Home Directory Helper + +private func realHomeDirectory() -> String { + guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } + return String(cString: pw.pointee.pw_dir) +} + +// MARK: - Timing Constants + +private enum Timing { + static let fallbackInterval: TimeInterval = 300.0 + static let debounceInterval: TimeInterval = 1.0 + static let refreshThreshold: TimeInterval = 2.0 +} + +// MARK: - Refresh Coordinator + +@MainActor +final class RefreshCoordinator { + private var fallbackTimerTask: Task? + private var dayChangeObserver: NSObjectProtocol? + private var lastKnownDay: String + private var lastRefreshTime: Date + private let clock: any ClockProtocol + private let monitoredPath: String + private let directoryMonitor: DirectoryMonitor + + var onRefresh: (() async -> Void)? + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + // MARK: - Initialization + + init( + clock: any ClockProtocol = SystemClock(), + refreshInterval: TimeInterval, + basePath: String = realHomeDirectory() + "/.claude" + ) { + self.clock = clock + self.lastRefreshTime = clock.now + self.lastKnownDay = Self.formatDay(clock.now) + self.monitoredPath = basePath + "/projects" + self.directoryMonitor = DirectoryMonitor(path: monitoredPath, debounceInterval: Timing.debounceInterval) + + setupDirectoryMonitor() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Public API + + func start() { + stop() + directoryMonitor.start() + startFallbackTimer() + startDayChangeMonitoring() + } + + func stop() { + directoryMonitor.stop() + stopFallbackTimer() + stopDayChangeMonitoring() + } + + func handleAppBecameActive() { + triggerRefreshIfNeeded() + start() + } + + func handleAppResignActive() { + stop() + } + + func handleWindowFocus() { + triggerRefreshIfNeeded() + } + + // MARK: - Refresh Logic + + private func triggerRefreshIfNeeded() { + guard shouldRefresh() else { return } + triggerRefresh() + } + + private func triggerRefresh() { + lastRefreshTime = clock.now + Task { await onRefresh?() } + } + + private func shouldRefresh() -> Bool { + clock.now.timeIntervalSince(lastRefreshTime) > Timing.refreshThreshold + } + + // MARK: - Directory Monitoring + + private func setupDirectoryMonitor() { + directoryMonitor.onChange = { [weak self] in + Task { @MainActor [weak self] in + self?.triggerRefreshIfNeeded() + } + } + } + + // MARK: - Fallback Timer + + private func startFallbackTimer() { + fallbackTimerTask = Task { @MainActor in + while !Task.isCancelled { + do { + try await Task.sleep(for: .seconds(Timing.fallbackInterval)) + guard !Task.isCancelled else { break } + triggerRefresh() + } catch { + break + } + } + } + } + + private func stopFallbackTimer() { + fallbackTimerTask?.cancel() + fallbackTimerTask = nil + } + + // MARK: - Day Change Monitoring + + private func startDayChangeMonitoring() { + stopDayChangeMonitoring() + observeCalendarDayChange() + observeSystemClockChange() + } + + private func stopDayChangeMonitoring() { + dayChangeObserver.map { NotificationCenter.default.removeObserver($0) } + dayChangeObserver = nil + } + + private func observeCalendarDayChange() { + dayChangeObserver = NotificationCenter.default.addObserver( + forName: .NSCalendarDayChanged, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.handleDayChange() + } + } + } + + private func observeSystemClockChange() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleSignificantTimeChange), + name: NSNotification.Name.NSSystemClockDidChange, + object: nil + ) + } + + private func handleDayChange() { + lastKnownDay = Self.formatDay(clock.now) + triggerRefresh() + } + + @objc private func handleSignificantTimeChange() { + Task { @MainActor in + let currentDay = Self.formatDay(clock.now) + guard currentDay != lastKnownDay else { return } + lastKnownDay = currentDay + triggerRefresh() + } + } + + // MARK: - Pure Functions + + private static func formatDay(_ date: Date) -> String { + dayFormatter.string(from: date) + } +} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift new file mode 100644 index 0000000..d5f9544 --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift @@ -0,0 +1,256 @@ +// +// UsageStore.swift +// Observable state container for usage data +// + +import SwiftUI +import Observation +import ClaudeUsageCore +import ClaudeUsageData + +// MARK: - Usage Store + +@Observable +@MainActor +public final class UsageStore { + // MARK: - Source State + + private(set) var state: ViewState = .loading + private(set) var activeSession: SessionBlock? + private(set) var burnRate: BurnRate? + private(set) var todayEntries: [UsageEntry] = [] + + // MARK: - Configuration + + private let defaultThreshold: Double + + // MARK: - Derived Properties + + var isLoading: Bool { state.isLoading } + var stats: UsageStats? { state.stats } + + var todaysCost: Double { + todayEntries.reduce(0.0) { $0 + $1.costUSD } + } + + var dailyCostThreshold: Double { + deriveThreshold(from: stats, default: defaultThreshold) + } + + var todaysCostProgress: Double { + progressCapped(todaysCost / dailyCostThreshold) + } + + var sessionTimeProgress: Double { + activeSession.map { sessionProgress($0, now: clock.now) } ?? 0 + } + + var averageDailyCost: Double { + stats.flatMap { recentDaysAverage($0.byDate) } ?? 0 + } + + var todayHourlyCosts: [Double] { + UsageAggregator.todayHourlyCosts(from: todayEntries, referenceDate: clock.now) + } + + var formattedTodaysCost: String { + todaysCost.asCurrency + } + + // MARK: - Dependencies + + private let dataLoader: UsageDataLoader + private let clock: any ClockProtocol + private let refreshCoordinator: RefreshCoordinator + + // MARK: - Internal State + + private var isCurrentlyLoading = false + private var lastLoadStartTime: Date? + private var hasInitialized = false + private var lastHistoryLoadDate: Date? + + // MARK: - Initialization + + public convenience init() { + self.init(repository: nil, sessionMonitorService: nil, configurationService: nil, clock: SystemClock()) + } + + init( + repository: (any UsageDataSource)? = nil, + sessionMonitorService: SessionMonitorService? = nil, + configurationService: ConfigurationService? = nil, + clock: any ClockProtocol = SystemClock() + ) { + let config = configurationService ?? DefaultConfigurationService() + let repo = repository ?? UsageRepository(basePath: config.configuration.basePath) + let sessionService = sessionMonitorService ?? DefaultSessionMonitorService(configuration: config.configuration) + + self.dataLoader = UsageDataLoader(repository: repo, sessionMonitorService: sessionService) + self.clock = clock + self.defaultThreshold = config.configuration.dailyCostThreshold + self.refreshCoordinator = RefreshCoordinator( + clock: clock, + refreshInterval: config.configuration.refreshInterval, + basePath: config.configuration.basePath + ) + + refreshCoordinator.onRefresh = { [weak self] in + await self?.loadData() + } + } + + // MARK: - Public API + + func initializeIfNeeded() async { + guard !hasInitialized else { return } + hasInitialized = true + + if !state.hasLoaded { + await loadData() + } + refreshCoordinator.start() + } + + func loadData() async { + guard canStartLoad else { return } + await trackLoadExecution { try await executeLoad() } + } + + // MARK: - Load Execution + + private var canStartLoad: Bool { + !isCurrentlyLoading && !isLoadedRecently + } + + private func trackLoadExecution(_ load: () async throws -> Void) async { + isCurrentlyLoading = true + lastLoadStartTime = clock.now + _ = await LoadTrace.shared.start() + defer { isCurrentlyLoading = false } + + do { + try await load() + await LoadTrace.shared.complete() + } catch { + state = .error(error) + } + } + + private func executeLoad() async throws { + let todayResult = try await dataLoader.loadToday() + apply(todayResult) + try await loadHistoryIfNeeded() + } + + private func loadHistoryIfNeeded() async throws { + guard shouldLoadHistory else { + await LoadTrace.shared.skipHistory() + return + } + let historyResult = try await dataLoader.loadHistory() + apply(historyResult) + lastHistoryLoadDate = clock.now + } + + private var shouldLoadHistory: Bool { + guard let lastDate = lastHistoryLoadDate else { return true } + return !Calendar.current.isDate(lastDate, inSameDayAs: clock.now) + } + + // MARK: - State Transitions + + private func apply(_ result: TodayLoadResult) { + activeSession = result.session + burnRate = result.burnRate + todayEntries = result.todayEntries + + if case .loaded = state { return } + state = .loadedToday(result.todayStats) + } + + private func apply(_ result: FullLoadResult) { + state = .loaded(result.fullStats) + } + + func refresh() async { + await loadData() + } + + // MARK: - Lifecycle + + func handleAppBecameActive() { + refreshCoordinator.handleAppBecameActive() + } + + func handleAppResignActive() { + refreshCoordinator.handleAppResignActive() + } + + func handleWindowFocus() { + refreshCoordinator.handleWindowFocus() + } + + func stopRefreshTimer() { + refreshCoordinator.stop() + } + + // MARK: - Helpers + + private var isLoadedRecently: Bool { + guard let lastTime = lastLoadStartTime else { return false } + return clock.now.timeIntervalSince(lastTime) < 2.0 + } +} + +// MARK: - Supporting Types + +enum ViewState { + case loading + case loadedToday(UsageStats) + case loaded(UsageStats) + case error(Error) + + var isLoading: Bool { + if case .loading = self { return true } + return false + } + + var hasLoaded: Bool { + switch self { + case .loadedToday, .loaded: return true + default: return false + } + } + + var stats: UsageStats? { + if case .loaded(let stats) = self { return stats } + return nil + } +} + +// MARK: - Pure Functions + +private func deriveThreshold(from stats: UsageStats?, default defaultThreshold: Double) -> Double { + guard let stats = stats, !stats.byDate.isEmpty else { return defaultThreshold } + let average = recentDaysAverage(stats.byDate) ?? 0 + return average > 0 ? max(average * 1.5, 10.0) : defaultThreshold +} + +private func recentDaysAverage(_ byDate: [DailyUsage]) -> Double? { + guard !byDate.isEmpty else { return nil } + let recentDays = byDate.suffix(7) + return recentDays.reduce(0.0) { $0 + $1.totalCost } / Double(recentDays.count) +} + +private func progressCapped(_ value: Double) -> Double { + min(value, 1.5) +} + +private func sessionProgress(_ session: SessionBlock, now: Date) -> Double { + // Progress = time since session started / 5h window + let elapsed = now.timeIntervalSince(session.startTime) + let total = session.endTime.timeIntervalSince(session.startTime) + guard total > 0 else { return 0 } + return min(elapsed / total, 1.0) +} diff --git a/Tests/ClaudeUsageTests/HeatmapStoreTests.swift b/Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/HeatmapStoreTests.swift similarity index 100% rename from Tests/ClaudeUsageTests/HeatmapStoreTests.swift rename to Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/HeatmapStoreTests.swift diff --git a/Tests/ClaudeUsageTests/UsageDataLoaderTests.swift b/Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/UsageDataLoaderTests.swift similarity index 100% rename from Tests/ClaudeUsageTests/UsageDataLoaderTests.swift rename to Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/UsageDataLoaderTests.swift diff --git a/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift b/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift new file mode 100644 index 0000000..943c959 --- /dev/null +++ b/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift @@ -0,0 +1 @@ +@_exported import ClaudeUsageCore diff --git a/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift b/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift new file mode 100644 index 0000000..c851100 --- /dev/null +++ b/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift @@ -0,0 +1 @@ +@_exported import ClaudeUsageData diff --git a/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift b/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift new file mode 100644 index 0000000..d3cd671 --- /dev/null +++ b/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift @@ -0,0 +1 @@ +@_exported import ClaudeUsageUI From c7c5c0318646847603b9eef82359c427f9e2db17 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:00:07 +0100 Subject: [PATCH 2/9] fix(preview): add preview detection to avoid MenuBarExtra blocking app launch - Detect preview environment via XCODE_RUNNING_FOR_PLAYGROUNDS - Use minimal placeholder content in MenuBarExtra during previews - Add ClaudeUsageUI dependency to Xcode project - Remove duplicated source files now imported from package --- ClaudeCodeUsage.xcodeproj/project.pbxproj | 12 + ClaudeCodeUsage/AppLifecycleManager.swift | 53 --- ClaudeCodeUsage/ClaudeCodeUsageApp.swift | 8 +- .../MainWindow/Analytics/AnalyticsRows.swift | 105 ----- .../MainWindow/Analytics/AnalyticsView.swift | 107 ----- .../Analytics/Cards/AnalyticsCard.swift | 33 -- .../Analytics/Cards/PredictionsCard.swift | 58 --- .../Cards/TokenDistributionCard.swift | 46 --- .../Analytics/Cards/UsageTrendsCard.swift | 115 ------ .../Cards/YearlyCostHeatmapCard.swift | 79 ---- .../HeatmapConfiguration+Accessibility.swift | 81 ---- .../HeatmapConfiguration+ColorThemes.swift | 172 -------- .../Configuration/HeatmapConfiguration.swift | 205 ---------- .../Analytics/Heatmap/Grid/DaySquare.swift | 98 ----- .../Analytics/Heatmap/Grid/HeatmapGrid.swift | 298 -------------- .../Heatmap/Grid/HeatmapGridLayout.swift | 53 --- .../Heatmap/Grid/HeatmapGridPerformance.swift | 53 --- .../Analytics/Heatmap/Grid/WeekColumn.swift | 29 -- .../Legend/HeatmapLegend+Factory.swift | 60 --- .../Heatmap/Legend/HeatmapLegend.swift | 297 -------------- .../Heatmap/Legend/HeatmapLegendBuilder.swift | 83 ---- .../Heatmap/Legend/LegendSquare.swift | 44 --- .../Heatmap/Models/ActivityLevel.swift | 43 -- .../Heatmap/Models/ActivityLevelLabels.swift | 22 -- .../Heatmap/Models/BorderStyle.swift | 25 -- .../Heatmap/Models/ColorScheme.swift | 352 ----------------- .../Heatmap/Models/DateConstants.swift | 13 - .../Heatmap/Models/DateRangeValidation.swift | 66 ---- .../Heatmap/Models/HeatmapData.swift | 347 ---------------- .../Models/HeatmapDateCalculator.swift | 301 -------------- .../Heatmap/Models/MonthOperations.swift | 40 -- .../Heatmap/Models/WeekOperations.swift | 56 --- .../Stores/HeatmapStore+DataGeneration.swift | 238 ----------- .../Stores/HeatmapStore+SupportingTypes.swift | 98 ----- .../Heatmap/Stores/HeatmapStore.swift | 328 --------------- .../Tooltip/HeatmapTooltip+Factory.swift | 39 -- .../Heatmap/Tooltip/HeatmapTooltip.swift | 373 ------------------ .../Tooltip/HeatmapTooltipBuilder.swift | 66 ---- .../Tooltip/TooltipConfiguration.swift | 68 ---- .../Heatmap/Tooltip/TooltipPositioning.swift | 61 --- .../Heatmap/YearlyCostHeatmap+Factories.swift | 155 -------- .../Heatmap/YearlyCostHeatmap+Preview.swift | 105 ----- .../Analytics/Heatmap/YearlyCostHeatmap.swift | 292 -------------- .../Components/EmptyStateView.swift | 43 -- .../MainWindow/Daily/DailyUsageView.swift | 303 -------------- ClaudeCodeUsage/MainWindow/MainView.swift | 149 ------- .../MainWindow/Models/ModelsView.swift | 254 ------------ .../MainWindow/Overview/MetricCard.swift | 30 -- .../MainWindow/Overview/OverviewView.swift | 229 ----------- .../MenuBar/Components/ActionButtons.swift | 134 ------- .../MenuBar/Components/GraphView.swift | 244 ------------ .../MenuBar/Components/MetricRow.swift | 147 ------- .../MenuBar/Components/ProgressBar.swift | 92 ----- .../MenuBar/Components/SectionHeader.swift | 47 --- .../MenuBar/Components/SettingsMenu.swift | 85 ---- .../MenuBar/Helpers/ColorService.swift | 122 ------ .../MenuBar/Helpers/FormatterService.swift | 149 ------- .../MenuBar/MenuBarContentView.swift | 158 -------- ClaudeCodeUsage/MenuBar/MenuBarScene.swift | 282 ------------- .../MenuBar/Sections/CostMetricsSection.swift | 169 -------- .../Sections/SessionMetricsSection.swift | 31 -- .../Sections/UsageMetricsSection.swift | 72 ---- .../MenuBar/Theme/MenuBarStyles.swift | 61 --- .../MenuBar/Theme/MenuBarTheme.swift | 167 -------- .../Settings/AppSettingsService.swift | 151 ------- .../Settings/OpenAtLoginToggle.swift | 96 ----- .../HourlyChart/HourlyChartModels.swift | 118 ------ .../HourlyChart/HourlyCostChartSimple.swift | 250 ------------ .../HourlyChart/HourlyTooltipViews.swift | 75 ---- .../Stores/Services/Clock/ClockProtocol.swift | 123 ------ .../Stores/Services/Clock/SystemClock.swift | 24 -- .../Stores/Services/Clock/TestClock.swift | 104 ----- .../Services/Loading/AppConfiguration.swift | 51 --- .../Stores/Services/Loading/LoadTrace.swift | 176 --------- .../Loading/SessionMonitorService.swift | 76 ---- .../Services/Loading/UsageDataLoader.swift | 142 ------- .../Stores/Services/RefreshCoordinator.swift | 193 --------- ClaudeCodeUsage/Stores/UsageStore.swift | 256 ------------ .../ClaudeUsageUI/MenuBar/MenuBarScene.swift | 29 +- 79 files changed, 36 insertions(+), 9973 deletions(-) delete mode 100644 ClaudeCodeUsage/AppLifecycleManager.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift delete mode 100644 ClaudeCodeUsage/MainWindow/MainView.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Models/ModelsView.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift delete mode 100644 ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/GraphView.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/MetricRow.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift delete mode 100644 ClaudeCodeUsage/MenuBar/MenuBarContentView.swift delete mode 100644 ClaudeCodeUsage/MenuBar/MenuBarScene.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift delete mode 100644 ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift delete mode 100644 ClaudeCodeUsage/Settings/AppSettingsService.swift delete mode 100644 ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift delete mode 100644 ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift delete mode 100644 ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift delete mode 100644 ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift delete mode 100644 ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift delete mode 100644 ClaudeCodeUsage/Stores/UsageStore.swift diff --git a/ClaudeCodeUsage.xcodeproj/project.pbxproj b/ClaudeCodeUsage.xcodeproj/project.pbxproj index 6a080a4..64d12cd 100644 --- a/ClaudeCodeUsage.xcodeproj/project.pbxproj +++ b/ClaudeCodeUsage.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ ECF4105F2F03EA5900DFC0C8 /* ClaudeUsageData in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4105E2F03EA5900DFC0C8 /* ClaudeUsageData */; }; ECF4108D2F03EC6700DFC0C8 /* ClaudeUsageCore in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4108C2F03EC6700DFC0C8 /* ClaudeUsageCore */; }; ECF410902F03EC7800DFC0C8 /* ClaudeUsageData in Frameworks */ = {isa = PBXBuildFile; productRef = ECF4108F2F03EC7800DFC0C8 /* ClaudeUsageData */; }; + ECF412B72F03F5BB00DFC0C8 /* ClaudeUsageUI in Frameworks */ = {isa = PBXBuildFile; productRef = ECF412B62F03F5BB00DFC0C8 /* ClaudeUsageUI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,6 +64,7 @@ files = ( ECF4105F2F03EA5900DFC0C8 /* ClaudeUsageData in Frameworks */, ECF410592F03E6FD00DFC0C8 /* ClaudeUsageData in Frameworks */, + ECF412B72F03F5BB00DFC0C8 /* ClaudeUsageUI in Frameworks */, ECF410572F03E6FD00DFC0C8 /* ClaudeUsageCore in Frameworks */, ECF410902F03EC7800DFC0C8 /* ClaudeUsageData in Frameworks */, ECF4105C2F03EA3E00DFC0C8 /* ClaudeUsageCore in Frameworks */, @@ -133,6 +135,7 @@ ECF4105E2F03EA5900DFC0C8 /* ClaudeUsageData */, ECF4108C2F03EC6700DFC0C8 /* ClaudeUsageCore */, ECF4108F2F03EC7800DFC0C8 /* ClaudeUsageData */, + ECF412B62F03F5BB00DFC0C8 /* ClaudeUsageUI */, ); productName = ClaudeCodeUsage; productReference = ECF4102B2F03E6AD00DFC0C8 /* ClaudeCodeUsage.app */; @@ -219,6 +222,7 @@ packageReferences = ( ECF4108B2F03EC6700DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageCore" */, ECF4108E2F03EC7800DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageData" */, + ECF412B52F03F5BB00DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageUI" */, ); preferredProjectObjectVersion = 77; productRefGroup = ECF4102C2F03E6AD00DFC0C8 /* Products */; @@ -610,6 +614,10 @@ isa = XCLocalSwiftPackageReference; relativePath = Packages/ClaudeUsageData; }; + ECF412B52F03F5BB00DFC0C8 /* XCLocalSwiftPackageReference "Packages/ClaudeUsageUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/ClaudeUsageUI; + }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -637,6 +645,10 @@ isa = XCSwiftPackageProductDependency; productName = ClaudeUsageData; }; + ECF412B62F03F5BB00DFC0C8 /* ClaudeUsageUI */ = { + isa = XCSwiftPackageProductDependency; + productName = ClaudeUsageUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = ECF410232F03E6AD00DFC0C8 /* Project object */; diff --git a/ClaudeCodeUsage/AppLifecycleManager.swift b/ClaudeCodeUsage/AppLifecycleManager.swift deleted file mode 100644 index 2e5591d..0000000 --- a/ClaudeCodeUsage/AppLifecycleManager.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// AppLifecycleManager.swift -// Centralized app lifecycle and notification management -// - -import SwiftUI -import Observation -import Combine - -@Observable -@MainActor -public final class AppLifecycleManager { - private var cancellables = Set() - private weak var store: UsageStore? - - public init() { - setupNotificationHandlers() - } - - public func configure(with store: UsageStore) { - self.store = store - } - - private func setupNotificationHandlers() { - observe(NSApplication.didBecomeActiveNotification) { [weak self] in self?.handleAppBecameActive() } - observe(NSApplication.didResignActiveNotification) { [weak self] in self?.handleAppResignActive() } - observe(NSWindow.didBecomeKeyNotification) { [weak self] in self?.handleWindowFocus() } - observe(NSWindow.willCloseNotification) { [weak self] in self?.handleWindowWillClose() } - } - - private func observe(_ notification: NSNotification.Name, handler: @escaping () -> Void) { - NotificationCenter.default.publisher(for: notification) - .receive(on: DispatchQueue.main) - .sink { _ in handler() } - .store(in: &cancellables) - } - - private func handleAppBecameActive() { - store?.handleAppBecameActive() - } - - private func handleAppResignActive() { - store?.handleAppResignActive() - } - - private func handleWindowFocus() { - store?.handleWindowFocus() - } - - private func handleWindowWillClose() { - store?.stopRefreshTimer() - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/ClaudeCodeUsageApp.swift b/ClaudeCodeUsage/ClaudeCodeUsageApp.swift index bb85529..963b9e6 100644 --- a/ClaudeCodeUsage/ClaudeCodeUsageApp.swift +++ b/ClaudeCodeUsage/ClaudeCodeUsageApp.swift @@ -1,10 +1,3 @@ -// -// ClaudeCodeUsageApp.swift -// ClaudeCodeUsage -// -// Created by Liang on 30-12-2025. -// - // // ClaudeCodeUsageApp.swift // App entry point @@ -12,6 +5,7 @@ import SwiftUI import ClaudeUsageCore +import ClaudeUsageUI // MARK: - App Entry Point diff --git a/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift b/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift deleted file mode 100644 index ec1bf28..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsRows.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// AnalyticsRows.swift -// Shared row components for analytics cards -// - -import SwiftUI -import ClaudeUsageCore - -struct TokenRow: View { - let label: String - let percentage: Double - let icon: String - let color: Color - - var body: some View { - HStack { - Label(label, systemImage: icon) - .foregroundColor(color) - Spacer() - Text(percentage.asPercentage) - .font(.system(.body, design: .monospaced)) - } - } -} - -struct PredictionRow: View { - let label: String - let value: String - let icon: String - let detail: String? - - var body: some View { - HStack { - Label(label, systemImage: icon) - Spacer() - valueSection - } - } - - private var valueSection: some View { - VStack(alignment: .trailing) { - valueText - detailText - } - } - - private var valueText: some View { - Text(value) - .font(.system(.body, design: .monospaced)) - .fontWeight(.semibold) - } - - @ViewBuilder - private var detailText: some View { - if let detail { - Text(detail) - .font(.caption) - .foregroundColor(.secondary) - } - } -} - -struct TrendRow: View { - let trend: UsageTrend - - var body: some View { - HStack { - Label("7-Day Trend", systemImage: trend.icon) - .foregroundColor(trend.color) - Spacer() - Text(trend.formattedPercentage) - .font(.system(.body, design: .monospaced)) - .foregroundColor(trend.color) - } - } -} - -struct InfoRow: View { - let label: String - let value: String - let detail: String - - var body: some View { - HStack { - labelView - Spacer() - valueSection - } - } - - private var labelView: some View { - Text(label) - .foregroundColor(.secondary) - } - - private var valueSection: some View { - VStack(alignment: .trailing) { - Text(value) - .font(.subheadline) - Text(detail) - .font(.caption) - .foregroundColor(.secondary) - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift b/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift deleted file mode 100644 index 2493c07..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/AnalyticsView.swift +++ /dev/null @@ -1,107 +0,0 @@ -// -// AnalyticsView.swift -// Analytics and insights view -// - -import SwiftUI -import ClaudeUsageCore - -struct AnalyticsView: View { - @Environment(UsageStore.self) private var store - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - AnalyticsHeader() - AnalyticsContent(state: ContentState.from(store: store)) - } - .padding() - } - .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Content State - -@MainActor -private enum ContentState { - case loading - case loaded(UsageStats) - case error - - static func from(store: UsageStore) -> ContentState { - if store.isLoading { return .loading } - guard let stats = store.stats else { return .error } - return .loaded(stats) - } -} - -// MARK: - Content Router - -private struct AnalyticsContent: View { - let state: ContentState - - var body: some View { - switch state { - case .loading: - LoadingView(message: "Analyzing data...") - case .loaded(let stats): - AnalyticsCards(stats: stats) - case .error: - EmptyStateView( - icon: "chart.bar.xaxis", - title: "No Analytics Available", - message: "Analytics will appear once you have usage data." - ) - } - } -} - -private struct AnalyticsCards: View { - let stats: UsageStats - - var body: some View { - VStack(spacing: 16) { - YearlyCostHeatmapCard(stats: stats) - TokenDistributionCard(stats: stats) - PredictionsCard(stats: stats) - EfficiencyCard(stats: stats) - TrendsCard(stats: stats) - } - } -} - -// MARK: - Header - -private struct AnalyticsHeader: View { - var body: some View { - VStack(alignment: .leading, spacing: 8) { - titleView - subtitleView - } - } - - private var titleView: some View { - Text("Analytics") - .font(.largeTitle) - .fontWeight(.bold) - } - - private var subtitleView: some View { - Text("Insights and predictions based on your usage") - .font(.subheadline) - .foregroundColor(.secondary) - } -} - -// MARK: - Loading View - -private struct LoadingView: View { - let message: String - - var body: some View { - ProgressView(message) - .frame(maxWidth: .infinity) - .padding(.top, 50) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift deleted file mode 100644 index e059aa5..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Cards/AnalyticsCard.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AnalyticsCard.swift -// Reusable card container for analytics views -// - -import SwiftUI - -struct AnalyticsCard: View { - let title: String - let icon: String - let color: Color - @ViewBuilder let content: () -> Content - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - headerView - content() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(12) - } - - private var headerView: some View { - HStack { - Image(systemName: icon) - .foregroundColor(color) - Text(title) - .font(.headline) - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift deleted file mode 100644 index b936e4f..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Cards/PredictionsCard.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// PredictionsCard.swift -// Cost predictions based on usage data -// - -import SwiftUI -import ClaudeUsageCore - -struct PredictionsCard: View { - let stats: UsageStats - - private var metrics: PredictionMetrics { PredictionMetrics.from(stats: stats) } - - var body: some View { - AnalyticsCard(title: "Predictions", icon: "calendar", color: .green) { - VStack(alignment: .leading, spacing: 12) { - monthlyCostRow - dailyCostRow - } - } - } - - private var monthlyCostRow: some View { - PredictionRow( - label: "Predicted Monthly Cost", - value: metrics.monthlyCost.asCurrency, - icon: "calendar", - detail: "Based on \(metrics.daysElapsed) days of data" - ) - } - - @ViewBuilder - private var dailyCostRow: some View { - if let daily = metrics.averageDailyCost { - PredictionRow( - label: "Average Daily Cost", - value: daily.asCurrency, - icon: "chart.line.uptrend.xyaxis", - detail: nil - ) - } - } -} - -// MARK: - Pure Transformation - -private struct PredictionMetrics { - let daysElapsed: Int - let monthlyCost: Double - let averageDailyCost: Double? - - static func from(stats: UsageStats) -> PredictionMetrics { - let days = max(1, stats.byDate.count) - let monthly = UsageAnalytics.predictMonthlyCost(from: stats, daysElapsed: days) - let daily = stats.byDate.isEmpty ? nil : stats.totalCost / Double(stats.byDate.count) - return PredictionMetrics(daysElapsed: days, monthlyCost: monthly, averageDailyCost: daily) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift deleted file mode 100644 index ad965e5..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Cards/TokenDistributionCard.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// TokenDistributionCard.swift -// Token distribution visualization -// - -import SwiftUI -import ClaudeUsageCore - -struct TokenDistributionCard: View { - let stats: UsageStats - - var body: some View { - AnalyticsCard(title: "Token Distribution", icon: "chart.pie", color: .blue) { - TokenDistributionRows(breakdown: UsageAnalytics.tokenBreakdown(from: stats)) - } - } -} - -private struct TokenDistributionRows: View { - let breakdown: (inputPercentage: Double, outputPercentage: Double, cacheWritePercentage: Double, cacheReadPercentage: Double) - - var body: some View { - VStack(spacing: 12) { - inputRow - outputRow - cacheWriteRow - cacheReadRow - } - } - - private var inputRow: some View { - TokenRow(label: "Input", percentage: breakdown.inputPercentage, icon: "arrow.right.circle", color: .blue) - } - - private var outputRow: some View { - TokenRow(label: "Output", percentage: breakdown.outputPercentage, icon: "arrow.left.circle", color: .green) - } - - private var cacheWriteRow: some View { - TokenRow(label: "Cache Write", percentage: breakdown.cacheWritePercentage, icon: "square.and.pencil", color: .orange) - } - - private var cacheReadRow: some View { - TokenRow(label: "Cache Read", percentage: breakdown.cacheReadPercentage, icon: "doc.text.magnifyingglass", color: .purple) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift deleted file mode 100644 index 0765f36..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Cards/UsageTrendsCard.swift +++ /dev/null @@ -1,115 +0,0 @@ -// -// UsageTrendsCard.swift -// Usage trends and efficiency analytics -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Trends Card - -struct TrendsCard: View { - let stats: UsageStats - - var body: some View { - AnalyticsCard(title: "Usage Trends", icon: "chart.line.uptrend.xyaxis", color: .orange) { - VStack(alignment: .leading, spacing: 12) { - weeklyTrendSection - peakDaySection - } - } - } - - @ViewBuilder - private var weeklyTrendSection: some View { - if let trend = TrendCalculator.weeklyTrend(from: stats) { - TrendRow(trend: trend) - } - } - - @ViewBuilder - private var peakDaySection: some View { - if let peak = TrendCalculator.peakDay(from: stats) { - InfoRow( - label: "Peak Usage Day", - value: DateFormatting.formatMedium(peak.date), - detail: peak.totalCost.asCurrency - ) - } - } -} - -// MARK: - Efficiency Card - -struct EfficiencyCard: View { - let stats: UsageStats - - var body: some View { - AnalyticsCard(title: "Efficiency", icon: "memorychip", color: .purple) { - VStack(alignment: .leading, spacing: 12) { - Text(UsageAnalytics.cacheSavings(from: stats).description) - .font(.body) - .foregroundColor(.primary) - } - } - } -} - -// MARK: - Pure Transformations - -private enum TrendCalculator { - static func weeklyTrend(from stats: UsageStats) -> UsageTrend? { - guard stats.byDate.count >= 2 else { return nil } - - let recent = stats.byDate.suffix(7) - let previous = stats.byDate.dropLast(7).suffix(7) - - guard !recent.isEmpty, !previous.isEmpty else { return nil } - - let recentAvg = recent.map(\.totalCost).reduce(0, +) / Double(recent.count) - let previousAvg = previous.map(\.totalCost).reduce(0, +) / Double(previous.count) - - let change = ((recentAvg - previousAvg) / previousAvg) * 100 - return UsageTrend(direction: change > 0 ? .up : .down, percentage: abs(change)) - } - - static func peakDay(from stats: UsageStats) -> DailyUsage? { - stats.byDate.max { $0.totalCost < $1.totalCost } - } -} - -private enum DateFormatting { - private static let inputFormatter: DateFormatter = { - let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd" - return f - }() - - static func formatMedium(_ dateString: String) -> String { - guard let date = inputFormatter.date(from: dateString) else { return dateString } - let output = DateFormatter() - output.dateStyle = .medium - return output.string(from: date) - } -} - -// MARK: - Supporting Types - -struct UsageTrend { - enum Direction { case up, down } - - let direction: Direction - let percentage: Double - - var icon: String { - direction == .up ? "arrow.up.right" : "arrow.down.right" - } - - var color: Color { - direction == .up ? .red : .green - } - - var formattedPercentage: String { - "\(direction == .up ? "+" : "-")\(percentage.asPercentage)" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift b/ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift deleted file mode 100644 index 727c172..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// YearlyCostHeatmapCard.swift -// Yearly cost heatmap visualization -// - -import SwiftUI -import ClaudeUsageCore - -struct YearlyCostHeatmapCard: View { - let stats: UsageStats - @State private var selectedYear: Int = Calendar.current.component(.year, from: Date()) - - private var availableYears: [Int] { YearExtractor.years(from: stats.byDate) } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - HeatmapHeader(years: availableYears, selectedYear: $selectedYear) - YearlyCostHeatmap(stats: stats, year: selectedYear) - } - .onAppear { - if let mostRecent = availableYears.first { - selectedYear = mostRecent - } - } - } -} - -private struct HeatmapHeader: View { - let years: [Int] - @Binding var selectedYear: Int - - var body: some View { - HStack { - Spacer() - if years.count > 1 { - YearSelector(years: years, selectedYear: $selectedYear) - } - } - } -} - -private struct YearSelector: View { - let years: [Int] - @Binding var selectedYear: Int - - var body: some View { - Menu { - ForEach(years, id: \.self) { year in - Button(String(year)) { selectedYear = year } - } - } label: { - menuLabel - } - .buttonStyle(.plain) - } - - private var menuLabel: some View { - HStack(spacing: 4) { - Text(String(selectedYear)) - .font(.subheadline) - .fontWeight(.medium) - Image(systemName: "chevron.down") - .font(.caption) - } - .foregroundColor(.primary) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.gray.opacity(0.1)) - .cornerRadius(6) - } -} - -// MARK: - Pure Transformation - -private enum YearExtractor { - static func years(from dates: [DailyUsage]) -> [Int] { - Array(Set(dates.compactMap { Int($0.date.prefix(4)) })).sorted(by: >) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift deleted file mode 100644 index 6d92b2e..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// HeatmapConfiguration+Accessibility.swift -// -// Accessibility settings and validation for heatmap configuration. -// - -import Foundation - -// MARK: - Accessibility Settings - -/// Accessibility configuration for the heatmap -public struct HeatmapAccessibility: Equatable, Sendable { - - /// Whether to provide accessibility labels - public let enableAccessibilityLabels: Bool - - /// Whether to provide accessibility values - public let enableAccessibilityValues: Bool - - /// Whether to group accessibility elements - public let groupAccessibilityElements: Bool - - /// Custom accessibility prefix for dates - public let dateAccessibilityPrefix: String - - /// Custom accessibility prefix for costs - public let costAccessibilityPrefix: String - - /// Default accessibility configuration - public static let `default` = HeatmapAccessibility( - enableAccessibilityLabels: true, - enableAccessibilityValues: true, - groupAccessibilityElements: true, - dateAccessibilityPrefix: "Usage on", - costAccessibilityPrefix: "Cost:" - ) - - /// Disabled accessibility for performance-critical scenarios - public static let disabled = HeatmapAccessibility( - enableAccessibilityLabels: false, - enableAccessibilityValues: false, - groupAccessibilityElements: false, - dateAccessibilityPrefix: "", - costAccessibilityPrefix: "" - ) -} - -// MARK: - Validation - -public extension HeatmapConfiguration { - - /// Validates the configuration and returns any issues - func validate() -> [String] { - ValidationRules.validate(self) - } - - /// Whether the configuration is valid - var isValid: Bool { - validate().isEmpty - } -} - -// MARK: - Validation Rules - -enum ValidationRules { - /// All validation rules as closures that return optional error message - static let rules: [@Sendable (HeatmapConfiguration) -> String?] = [ - { $0.squareSize <= 0 ? "Square size must be greater than 0" : nil }, - { $0.spacing < 0 ? "Spacing cannot be negative" : nil }, - { $0.cornerRadius < 0 ? "Corner radius cannot be negative" : nil }, - { $0.tooltipDelay < 0 ? "Tooltip delay cannot be negative" : nil }, - { $0.animationDuration < 0 ? "Animation duration cannot be negative" : nil }, - { $0.hoverScale <= 0 ? "Hover scale must be greater than 0" : nil }, - { $0.todayHighlightWidth < 0 ? "Today highlight width cannot be negative" : nil } - ] - - /// Validate configuration and return all errors - static func validate(_ config: HeatmapConfiguration) -> [String] { - rules.compactMap { $0(config) } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift deleted file mode 100644 index 365321c..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift +++ /dev/null @@ -1,172 +0,0 @@ -// -// HeatmapConfiguration+ColorThemes.swift -// -// Color theme definitions for heatmap visualization. -// - -import SwiftUI - -// MARK: - Color Themes - -/// Predefined color themes for heatmap visualization -public enum HeatmapColorTheme: String, CaseIterable, Equatable, @unchecked Sendable { - case github = "github" - case ocean = "ocean" - case sunset = "sunset" - case forest = "forest" - case monochrome = "monochrome" - - /// Display name for the theme - public var displayName: String { - switch self { - case .github: return "GitHub" - case .ocean: return "Ocean" - case .sunset: return "Sunset" - case .forest: return "Forest" - case .monochrome: return "Monochrome" - } - } - - /// Colors for this theme (light mode - legacy) - public var colors: [Color] { - colors(for: .light) - } - - /// Colors for this theme based on color scheme - public func colors(for scheme: ColorScheme) -> [Color] { - switch self { - case .github: - return scheme == .dark ? githubDarkColors : githubLightColors - case .ocean: - return scheme == .dark ? oceanDarkColors : oceanLightColors - case .sunset: - return scheme == .dark ? sunsetDarkColors : sunsetLightColors - case .forest: - return scheme == .dark ? forestDarkColors : forestLightColors - case .monochrome: - return scheme == .dark ? monochromeDarkColors : monochromeLightColors - } - } - - /// Get color for specific intensity level (legacy - light mode) - public func color(for level: Int) -> Color { - color(for: level, scheme: .light) - } - - /// Get color for specific intensity level and color scheme - public func color(for level: Int, scheme: ColorScheme) -> Color { - let themeColors = colors(for: scheme) - let index = max(0, min(themeColors.count - 1, level)) - return themeColors[index] - } - - // MARK: - GitHub Theme Colors - - private var githubLightColors: [Color] { - [ - Color(red: 235/255, green: 237/255, blue: 240/255), // Level 0: #ebedf0 - Color(red: 155/255, green: 233/255, blue: 168/255), // Level 1: #9be9a8 - Color(red: 64/255, green: 196/255, blue: 99/255), // Level 2: #40c463 - Color(red: 48/255, green: 161/255, blue: 78/255), // Level 3: #30a14e - Color(red: 33/255, green: 110/255, blue: 57/255) // Level 4: #216e39 - ] - } - - private var githubDarkColors: [Color] { - [ - Color(red: 22/255, green: 27/255, blue: 34/255), // Level 0: #161b22 - Color(red: 14/255, green: 68/255, blue: 41/255), // Level 1: #0e4429 - Color(red: 0/255, green: 109/255, blue: 50/255), // Level 2: #006d32 - Color(red: 38/255, green: 166/255, blue: 65/255), // Level 3: #26a641 - Color(red: 57/255, green: 211/255, blue: 83/255) // Level 4: #39d353 - ] - } - - // MARK: - Ocean Theme Colors - - private var oceanLightColors: [Color] { - [ - Color.gray.opacity(0.3), - Color.blue.opacity(0.25), - Color.blue.opacity(0.45), - Color.blue.opacity(0.65), - Color.blue - ] - } - - private var oceanDarkColors: [Color] { - [ - Color(white: 0.15), - Color.blue.opacity(0.35), - Color.blue.opacity(0.55), - Color.blue.opacity(0.75), - Color(red: 0.3, green: 0.6, blue: 1.0) - ] - } - - // MARK: - Sunset Theme Colors - - private var sunsetLightColors: [Color] { - [ - Color.gray.opacity(0.3), - Color.yellow.opacity(0.4), - Color.orange.opacity(0.6), - Color.red.opacity(0.7), - Color.red - ] - } - - private var sunsetDarkColors: [Color] { - [ - Color(white: 0.15), - Color.yellow.opacity(0.5), - Color.orange.opacity(0.7), - Color.red.opacity(0.8), - Color(red: 1.0, green: 0.3, blue: 0.3) - ] - } - - // MARK: - Forest Theme Colors - - private var forestLightColors: [Color] { - [ - Color.gray.opacity(0.3), - Color.mint.opacity(0.3), - Color.green.opacity(0.5), - Color.green.opacity(0.7), - Color(red: 0, green: 0.5, blue: 0) - ] - } - - private var forestDarkColors: [Color] { - [ - Color(white: 0.15), - Color.mint.opacity(0.4), - Color.green.opacity(0.6), - Color.green.opacity(0.8), - Color(red: 0.2, green: 0.8, blue: 0.2) - ] - } - - // MARK: - Monochrome Theme Colors - - private var monochromeLightColors: [Color] { - [ - Color.gray.opacity(0.3), - Color.gray.opacity(0.5), - Color.gray.opacity(0.7), - Color.gray.opacity(0.85), - Color.gray - ] - } - - private var monochromeDarkColors: [Color] { - [ - Color(white: 0.15), - Color(white: 0.35), - Color(white: 0.5), - Color(white: 0.65), - Color(white: 0.8) - ] - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift deleted file mode 100644 index c15ee07..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift +++ /dev/null @@ -1,205 +0,0 @@ -// -// HeatmapConfiguration.swift -// Configuration models for heatmap customization -// -// Provides type-safe configuration options for heatmap appearance, -// layout, and behavior with sensible defaults. -// -// Split into extensions for focused responsibilities: -// - +ColorThemes: Color theme definitions -// - +Accessibility: Accessibility settings and validation -// - -import SwiftUI -import Foundation - -// MARK: - Configuration Struct - -/// Configuration settings for heatmap appearance and behavior -public struct HeatmapConfiguration: Equatable, @unchecked Sendable { - - // MARK: - Layout Settings - - /// Size of each day square in points - public let squareSize: CGFloat - - /// Spacing between day squares in points - public let spacing: CGFloat - - /// Corner radius for day squares - public let cornerRadius: CGFloat - - /// Padding around the entire heatmap - public let padding: EdgeInsets - - // MARK: - Visual Settings - - /// Color scheme for the heatmap - public let colorScheme: HeatmapColorTheme - - /// Whether to show month labels - public let showMonthLabels: Bool - - /// Whether to show day-of-week labels - public let showDayLabels: Bool - - /// Whether to show the legend - public let showLegend: Bool - - /// Font for month labels - public let monthLabelFont: Font - - /// Font for day labels - public let dayLabelFont: Font - - /// Font for legend text - public let legendFont: Font - - // MARK: - Interaction Settings - - /// Whether hover tooltips are enabled - public let enableTooltips: Bool - - /// Tooltip delay in seconds - public let tooltipDelay: Double - - /// Whether to highlight today's date - public let highlightToday: Bool - - /// Color for today's highlight border - public let todayHighlightColor: Color - - /// Width of today's highlight border - public let todayHighlightWidth: CGFloat - - // MARK: - Animation Settings - - /// Duration for hover animations - public let animationDuration: Double - - /// Whether to animate color transitions - public let animateColorTransitions: Bool - - /// Whether to scale squares on hover - public let scaleOnHover: Bool - - /// Scale factor for hover effect - public let hoverScale: CGFloat - - // MARK: - Default Configuration - - /// Standard configuration matching GitHub's contribution graph - public static let `default` = HeatmapConfiguration( - squareSize: 12, - spacing: 2, - cornerRadius: 2, - padding: EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), - colorScheme: .github, - showMonthLabels: true, - showDayLabels: true, - showLegend: true, - monthLabelFont: .system(size: 10, weight: .medium), - dayLabelFont: .system(size: 9, weight: .regular), - legendFont: .caption, - enableTooltips: true, - tooltipDelay: 0.5, - highlightToday: true, - todayHighlightColor: .blue, - todayHighlightWidth: 2, - animationDuration: 0.1, - animateColorTransitions: false, // Disabled for performance - scaleOnHover: false, // Disabled for performance - hoverScale: 1.1 - ) - - /// Compact configuration for smaller displays - public static let compact = HeatmapConfiguration( - squareSize: 10, - spacing: 1.5, - cornerRadius: 1.5, - padding: EdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12), - colorScheme: .github, - showMonthLabels: true, - showDayLabels: false, // Hide day labels in compact mode - showLegend: true, - monthLabelFont: .system(size: 9, weight: .medium), - dayLabelFont: .system(size: 8, weight: .regular), - legendFont: .system(size: 10), - enableTooltips: true, - tooltipDelay: 0.3, - highlightToday: true, - todayHighlightColor: .blue, - todayHighlightWidth: 1.5, - animationDuration: 0.08, - animateColorTransitions: false, - scaleOnHover: false, - hoverScale: 1.05 - ) - - /// Performance-optimized configuration - public static let performanceOptimized = HeatmapConfiguration( - squareSize: 12, - spacing: 2, - cornerRadius: 2, - padding: EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16), - colorScheme: .github, - showMonthLabels: true, - showDayLabels: true, - showLegend: true, - monthLabelFont: .system(size: 10, weight: .medium), - dayLabelFont: .system(size: 9, weight: .regular), - legendFont: .caption, - enableTooltips: true, - tooltipDelay: 0.0, // No delay for immediate feedback - highlightToday: true, - todayHighlightColor: .blue, - todayHighlightWidth: 2, - animationDuration: 0.0, // No animations for maximum performance - animateColorTransitions: false, - scaleOnHover: false, - hoverScale: 1.0 - ) - - // MARK: - Computed Properties - - /// Total cell size including spacing - public var cellSize: CGFloat { - squareSize + spacing - } - - /// Array of day labels based on configuration - public var dayLabels: [String] { - showDayLabels ? DayLabelsConstants.withLabels : DayLabelsConstants.empty - } -} - -// MARK: - Layout Constants - -/// Constants for heatmap layout calculations -public struct HeatmapLayoutConstants { - - /// Number of days in a week - public static let daysPerWeek = 7 - - /// Number of weeks to display (approximately 52-53 weeks) - public static let weeksPerYear = 53 - - /// Number of days in a rolling year - public static let rollingYearDays = 365 - - /// Minimum tooltip offset from cursor - public static let tooltipOffset = CGPoint(x: 10, y: -30) - - /// Default animation curve - public static let defaultAnimationCurve = Animation.easeInOut - - /// Performance threshold for number of hover targets - public static let performanceThreshold = 400 -} - -// MARK: - Day Labels Constants - -enum DayLabelsConstants { - static let withLabels = ["", "Mon", "", "Wed", "", "Fri", ""] - static let empty = Array(repeating: "", count: 7) -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift deleted file mode 100644 index c0314c5..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// DaySquare.swift -// Day square components for heatmap grid -// - -import SwiftUI - -// MARK: - Day Square Container - -/// Container for day squares handling both filled and empty states -struct DaySquareContainer: View { - let day: HeatmapDay? - let configuration: HeatmapConfiguration - let isHovered: Bool - let accessibility: HeatmapAccessibility - - var body: some View { - Group { - if let day = day { - DaySquare( - day: day, - configuration: configuration, - isHovered: isHovered, - accessibility: accessibility - ) - } else { - // Empty placeholder for consistent grid layout - Rectangle() - .fill(Color.clear) - .frame(width: configuration.squareSize, height: configuration.squareSize) - } - } - } -} - -// MARK: - Day Square - -/// Individual day square component with optimized rendering -struct DaySquare: View { - let day: HeatmapDay - let configuration: HeatmapConfiguration - let isHovered: Bool - let accessibility: HeatmapAccessibility - - @Environment(\.colorScheme) private var colorScheme - - // MARK: - Computed Properties - - private var dayColor: Color { - day.color(for: colorScheme) - } - - private var borderStyle: BorderStyle { - BorderStyle.forDay(day, isHovered: isHovered, config: configuration) - } - - private var scaleEffect: CGFloat { - configuration.scaleOnHover && isHovered ? configuration.hoverScale : 1.0 - } - - private var hoverAnimation: Animation? { - configuration.animationDuration > 0 - ? .easeInOut(duration: configuration.animationDuration) - : nil - } - - // MARK: - Body - - var body: some View { - Rectangle() - .fill(dayColor) - .frame(width: configuration.squareSize, height: configuration.squareSize) - .cornerRadius(configuration.cornerRadius) - .overlay( - RoundedRectangle(cornerRadius: configuration.cornerRadius) - .stroke(borderStyle.color, lineWidth: borderStyle.width) - ) - .scaleEffect(scaleEffect) - .animation(hoverAnimation, value: isHovered) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(accessibilityValue) - } - - // MARK: - Accessibility - - private var accessibilityLabel: String { - accessibility.enableAccessibilityLabels - ? "\(accessibility.dateAccessibilityPrefix) \(day.dateString)" - : "" - } - - private var accessibilityValue: String { - accessibility.enableAccessibilityValues - ? "\(accessibility.costAccessibilityPrefix) \(day.costString)" - : "" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift deleted file mode 100644 index bd82ce8..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift +++ /dev/null @@ -1,298 +0,0 @@ -// -// HeatmapGrid.swift -// Reusable grid component for heatmap visualization -// - -import SwiftUI - -// MARK: - Heatmap Grid - -/// High-performance grid component for rendering heatmap data -public struct HeatmapGrid: View { - - // MARK: - Properties - - /// Heatmap dataset to display - let dataset: HeatmapDataset - - /// Configuration for grid appearance and behavior - let configuration: HeatmapConfiguration - - /// Currently hovered day (optional) - let hoveredDay: HeatmapDay? - - /// Hover event handler - let onHover: (CGPoint) -> Void - - /// End hover event handler - let onEndHover: () -> Void - - /// Accessibility configuration - private let accessibility: HeatmapAccessibility - - // MARK: - Initialization - - /// Initialize heatmap grid - /// - Parameters: - /// - dataset: Data to display - /// - configuration: Grid configuration - /// - hoveredDay: Currently hovered day - /// - accessibility: Accessibility settings - /// - onHover: Hover event handler - /// - onEndHover: End hover handler - public init( - dataset: HeatmapDataset, - configuration: HeatmapConfiguration, - hoveredDay: HeatmapDay? = nil, - accessibility: HeatmapAccessibility = .default, - onHover: @escaping (CGPoint) -> Void, - onEndHover: @escaping () -> Void - ) { - self.dataset = dataset - self.configuration = configuration - self.hoveredDay = hoveredDay - self.accessibility = accessibility - self.onHover = onHover - self.onEndHover = onEndHover - } - - // MARK: - Public API (High Level) - - public var body: some View { - VStack(spacing: 8) { - mainGridLayout - } - } - - // MARK: - Orchestration (Coordination) - - @ViewBuilder - private var mainGridLayout: some View { - HStack(alignment: .top, spacing: 8) { - dayLabelsSidebar - scrollableGridWithMonthLabels - } - } - - @ViewBuilder - private var dayLabelsSidebar: some View { - if configuration.showDayLabels { - VStack(spacing: 0) { - monthLabelsSpacer - dayLabelsColumn - } - } - } - - @ViewBuilder - private var scrollableGridWithMonthLabels: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - VStack(spacing: 8) { - monthLabelsRowIfNeeded - gridWithHoverOverlay - } - } - .onAppear { - scrollToLastWeekWithData(proxy: proxy) - } - } - .accessibilityElement(children: accessibility.groupAccessibilityElements ? .contain : .ignore) - .accessibilityLabel("Heatmap grid showing daily usage over time") - } - - private func scrollToLastWeekWithData(proxy: ScrollViewProxy) { - guard let lastWeekWithData = dataset.weeks.last(where: { $0.totalCost > 0 }) else { return } - proxy.scrollTo(lastWeekWithData.id, anchor: .trailing) - } - - @ViewBuilder - private var gridWithHoverOverlay: some View { - ZStack { - gridContent - hoverOverlayIfEnabled - } - } - - // MARK: - Content Builders (Mid Level) - - @ViewBuilder - private var monthLabelsSpacer: some View { - if configuration.showMonthLabels { - Spacer().frame(height: 20) - } - } - - @ViewBuilder - private var monthLabelsRowIfNeeded: some View { - if configuration.showMonthLabels { - monthLabelsRow - } - } - - @ViewBuilder - private var monthLabelsRow: some View { - ZStack(alignment: .topLeading) { - monthLabelsBackground - monthLabelItems - } - } - - @ViewBuilder - private var monthLabelsBackground: some View { - Rectangle() - .fill(Color.clear) - .frame(width: totalGridWidth, height: 20) - } - - @ViewBuilder - private var monthLabelItems: some View { - ForEach(dataset.monthLabels) { month in - monthLabel(for: month) - } - } - - @ViewBuilder - private func monthLabel(for month: HeatmapMonth) -> some View { - Text(month.name) - .font(configuration.monthLabelFont) - .foregroundColor(.secondary) - .accessibilityLabel(accessibility.enableAccessibilityLabels ? month.fullName : "") - .offset(x: monthLabelOffset(for: month), y: 0) - } - - @ViewBuilder - private var dayLabelsColumn: some View { - VStack(spacing: configuration.spacing) { - ForEach(Array(configuration.dayLabels.enumerated()), id: \.offset) { _, dayLabel in - dayLabelView(dayLabel) - } - } - } - - @ViewBuilder - private func dayLabelView(_ label: String) -> some View { - Text(label) - .font(configuration.dayLabelFont) - .foregroundColor(.secondary) - .frame(width: 28, height: configuration.squareSize, alignment: .trailing) - .accessibilityHidden(!accessibility.enableAccessibilityLabels) - } - - @ViewBuilder - private var gridContent: some View { - HStack(spacing: configuration.spacing) { - ForEach(dataset.weeks) { week in - WeekColumn( - week: week, - configuration: configuration, - hoveredDay: hoveredDay, - accessibility: accessibility - ) - .id(week.id) - } - } - .padding(.horizontal, 4) - } - - @ViewBuilder - private var hoverOverlayIfEnabled: some View { - if configuration.enableTooltips { - hoverOverlay - } - } - - @ViewBuilder - private var hoverOverlay: some View { - Rectangle() - .fill(Color.clear) - .contentShape(Rectangle()) - .gesture(dragGesture) - .onContinuousHover(perform: handleHoverPhase) - } - - // MARK: - Layout Calculations (Low Level) - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 0) - .onChanged { value in - onHover(value.location) - } - } - - private func handleHoverPhase(_ phase: HoverPhase) { - switch phase { - case .active(let location): - onHover(location) - case .ended: - onEndHover() - } - } - - private func monthLabelOffset(for month: HeatmapMonth) -> CGFloat { - let weekStartIndex = CGFloat(month.weekSpan.lowerBound) - let squareOffset = weekStartIndex * configuration.squareSize - let spacingOffset = weekStartIndex * configuration.spacing - let horizontalPadding: CGFloat = 4 - return squareOffset + spacingOffset + horizontalPadding - } - - private var totalGridWidth: CGFloat { - let weekCount = CGFloat(dataset.weeks.count) - let totalSpacing = (weekCount - 1) * configuration.spacing - let totalSquares = weekCount * configuration.squareSize - let horizontalPadding: CGFloat = 8 - return totalSquares + totalSpacing + horizontalPadding - } -} - -// MARK: - Preview - -#if DEBUG -struct HeatmapGrid_Previews: PreviewProvider { - static var previews: some View { - let sampleData = generateSampleDataset() - - HeatmapGrid( - dataset: sampleData, - configuration: .default, - onHover: { _ in }, - onEndHover: { } - ) - .frame(height: 200) - .padding() - .background(Color(.controlBackgroundColor)) - } - - private static func generateSampleDataset() -> HeatmapDataset { - let weeks = (0..<52).map { weekIndex in - let days = (0..<7).map { dayIndex -> HeatmapDay? in - let cost = Double.random(in: 0...5) - let date = Calendar.current.date(byAdding: .day, value: weekIndex * 7 + dayIndex, to: Date())! - return HeatmapDay( - date: date, - cost: cost, - dayOfYear: weekIndex * 7 + dayIndex, - weekOfYear: weekIndex, - dayOfWeek: dayIndex, - maxCost: 5.0 - ) - } - return HeatmapWeek(weekNumber: weekIndex, days: days) - } - - let months = [ - HeatmapMonth(name: "Jan", weekSpan: 0..<4), - HeatmapMonth(name: "Feb", weekSpan: 4..<8), - HeatmapMonth(name: "Mar", weekSpan: 8..<13), - ] - - return HeatmapDataset( - weeks: weeks, - monthLabels: months, - maxCost: 5.0, - dateRange: Date()...Calendar.current.date(byAdding: .year, value: 1, to: Date())! - ) - } -} -#endif diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift deleted file mode 100644 index bac9eef..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// HeatmapGridLayout.swift -// Utility for calculating grid layout dimensions -// - -import SwiftUI - -// MARK: - Grid Layout Calculations - -/// Utility for calculating grid layout dimensions -public struct HeatmapGridLayout { - let configuration: HeatmapConfiguration - let dataset: HeatmapDataset - - /// Total width of the grid content - public var contentWidth: CGFloat { - let weekCount = CGFloat(dataset.weeks.count) - let totalSpacing = (weekCount - 1) * configuration.spacing - let totalSquares = weekCount * configuration.squareSize - return totalSquares + totalSpacing + 8 // 8 for padding - } - - /// Total height of the grid content - public var contentHeight: CGFloat { - let dayCount: CGFloat = 7 - let totalSpacing = (dayCount - 1) * configuration.spacing - let totalSquares = dayCount * configuration.squareSize - return totalSquares + totalSpacing - } - - /// Size required for the entire heatmap including labels - public var totalSize: CGSize { - let width = contentWidth + (configuration.showDayLabels ? 30 : 0) - let height = contentHeight + (configuration.showMonthLabels ? 20 : 0) - return CGSize(width: width, height: height) - } - - /// Calculate position of a day square - /// - Parameters: - /// - weekIndex: Week index in the grid - /// - dayIndex: Day index within the week - /// - Returns: Position of the day square - public func dayPosition(weekIndex: Int, dayIndex: Int) -> CGPoint { - // Calculate x position: (week_index * square_size) + (week_index * spacing) + horizontal_padding - let squareOffset = CGFloat(weekIndex) * configuration.squareSize - let spacingOffset = CGFloat(weekIndex) * configuration.spacing - let x = squareOffset + spacingOffset + 4 // 4 for horizontal padding - - // Calculate y position: (day_index * square_size) + (day_index * spacing) - let y = CGFloat(dayIndex) * configuration.cellSize - return CGPoint(x: x, y: y) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift deleted file mode 100644 index f737c1c..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// HeatmapGridPerformance.swift -// Performance optimization utilities for heatmap grid -// - -import SwiftUI - -// MARK: - Performance Optimizations - -/// Performance-optimized grid rendering strategies -public enum HeatmapGridPerformance { - - /// Maximum number of days before performance optimizations kick in - static let performanceThreshold = 400 - - /// Check if dataset requires performance optimizations - /// - Parameter dataset: Dataset to check - /// - Returns: True if performance optimizations should be applied - public static func requiresOptimization(for dataset: HeatmapDataset) -> Bool { - let totalDays = dataset.weeks.reduce(0) { count, week in - count + week.days.compactMap { $0 }.count - } - return totalDays > performanceThreshold - } - - /// Get recommended configuration for large datasets - /// - Parameter baseConfig: Base configuration to optimize - /// - Returns: Performance-optimized configuration - public static func optimizedConfiguration(from baseConfig: HeatmapConfiguration) -> HeatmapConfiguration { - HeatmapConfiguration( - squareSize: baseConfig.squareSize, - spacing: baseConfig.spacing, - cornerRadius: baseConfig.cornerRadius, - padding: baseConfig.padding, - colorScheme: baseConfig.colorScheme, - showMonthLabels: baseConfig.showMonthLabels, - showDayLabels: baseConfig.showDayLabels, - showLegend: baseConfig.showLegend, - monthLabelFont: baseConfig.monthLabelFont, - dayLabelFont: baseConfig.dayLabelFont, - legendFont: baseConfig.legendFont, - enableTooltips: baseConfig.enableTooltips, - tooltipDelay: 0.0, // No delay for better performance - highlightToday: baseConfig.highlightToday, - todayHighlightColor: baseConfig.todayHighlightColor, - todayHighlightWidth: baseConfig.todayHighlightWidth, - animationDuration: 0.0, // Disable animations - animateColorTransitions: false, - scaleOnHover: false, // Disable scaling - hoverScale: 1.0 - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift deleted file mode 100644 index cb357d5..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// WeekColumn.swift -// Single week column component for heatmap grid -// - -import SwiftUI - -// MARK: - Week Column - -/// Single week column in the heatmap grid -struct WeekColumn: View { - let week: HeatmapWeek - let configuration: HeatmapConfiguration - let hoveredDay: HeatmapDay? - let accessibility: HeatmapAccessibility - - var body: some View { - VStack(spacing: configuration.spacing) { - ForEach(0..<7, id: \.self) { dayIndex in - DaySquareContainer( - day: week.days[safe: dayIndex] ?? nil, - configuration: configuration, - isHovered: hoveredDay?.id == week.days[safe: dayIndex]??.id, - accessibility: accessibility - ) - } - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift deleted file mode 100644 index 317dc4e..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// HeatmapLegend+Factory.swift -// Convenience factory methods for common legend configurations -// - -import SwiftUI - -// MARK: - Convenience Extensions - -public extension HeatmapLegend { - - /// Create a minimal legend for compact displays - /// - Parameters: - /// - colorTheme: Color theme to display - /// - maxCost: Maximum cost for reference - /// - Returns: Configured minimal legend - static func minimal(colorTheme: HeatmapColorTheme, maxCost: Double) -> HeatmapLegend { - HeatmapLegend( - colorTheme: colorTheme, - maxCost: maxCost, - style: .compact, - showCostLabels: true, - showIntensityLabels: false - ) - } - - /// Create a detailed legend with all information - /// - Parameters: - /// - colorTheme: Color theme to display - /// - maxCost: Maximum cost for reference - /// - title: Optional custom title - /// - Returns: Configured detailed legend - static func detailed(colorTheme: HeatmapColorTheme, maxCost: Double, title: String? = nil) -> HeatmapLegend { - HeatmapLegend( - colorTheme: colorTheme, - maxCost: maxCost, - style: .horizontal, - showCostLabels: true, - showIntensityLabels: true, - customTitle: title - ) - } - - /// Create an accessibility-optimized legend - /// - Parameters: - /// - colorTheme: Color theme to display - /// - maxCost: Maximum cost for reference - /// - Returns: Accessibility-optimized legend - static func accessible(colorTheme: HeatmapColorTheme, maxCost: Double) -> HeatmapLegend { - HeatmapLegend( - colorTheme: colorTheme, - maxCost: maxCost, - style: .vertical, - font: .body, - showCostLabels: true, - showIntensityLabels: true, - accessibility: .default - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift deleted file mode 100644 index 08347b2..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift +++ /dev/null @@ -1,297 +0,0 @@ -// -// HeatmapLegend.swift -// Legend component for heatmap visualization -// - -import SwiftUI - -// MARK: - Heatmap Legend - -/// Reusable legend component for heatmap color scale -public struct HeatmapLegend: View { - - // MARK: - Configuration - - /// Legend layout style - public enum LegendStyle { - case horizontal - case vertical - case compact - } - - /// Legend position relative to parent - public enum LegendPosition { - case bottom - case top - case leading - case trailing - } - - // MARK: - Properties - - /// Color theme for the legend - let colorTheme: HeatmapColorTheme - - /// Maximum cost value for scale reference - let maxCost: Double - - /// Legend display style - let style: LegendStyle - - /// Font for legend text - let font: Font - - /// Whether to show cost labels - let showCostLabels: Bool - - /// Whether to show intensity labels (Less/More) - let showIntensityLabels: Bool - - /// Custom title for the legend - let customTitle: String? - - /// Accessibility configuration - private let accessibility: HeatmapAccessibility - - // MARK: - Initialization - - /// Initialize heatmap legend - /// - Parameters: - /// - colorTheme: Color theme to display - /// - maxCost: Maximum cost value for reference - /// - style: Display style (default: horizontal) - /// - font: Font for text (default: caption) - /// - showCostLabels: Whether to show cost values (default: true) - /// - showIntensityLabels: Whether to show Less/More labels (default: true) - /// - customTitle: Optional custom title - /// - accessibility: Accessibility configuration - public init( - colorTheme: HeatmapColorTheme, - maxCost: Double, - style: LegendStyle = .horizontal, - font: Font = .caption, - showCostLabels: Bool = true, - showIntensityLabels: Bool = true, - customTitle: String? = nil, - accessibility: HeatmapAccessibility = .default - ) { - self.colorTheme = colorTheme - self.maxCost = maxCost - self.style = style - self.font = font - self.showCostLabels = showCostLabels - self.showIntensityLabels = showIntensityLabels - self.customTitle = customTitle - self.accessibility = accessibility - } - - // MARK: - Body - - public var body: some View { - switch style { - case .horizontal: - horizontalLegend - case .vertical: - verticalLegend - case .compact: - compactLegend - } - } - - // MARK: - Legend Variants (Mid Level) - - @ViewBuilder - private var horizontalLegend: some View { - VStack(alignment: .leading, spacing: 4) { - legendTitle - horizontalLegendContent - } - .accessibilityElement(children: .combine) - .accessibilityLabel(legendAccessibilityLabel) - } - - @ViewBuilder - private var verticalLegend: some View { - VStack(alignment: .center, spacing: 8) { - legendTitle - verticalColorScale - conditionalCostReference - } - .accessibilityElement(children: .combine) - .accessibilityLabel(legendAccessibilityLabel) - } - - // MARK: - Legend Content (Mid Level) - - @ViewBuilder - private var horizontalLegendContent: some View { - HStack(spacing: 8) { - lessLabel - colorSquares - moreLabel - Spacer() - conditionalCostReference - } - } - - @ViewBuilder - private var verticalColorScale: some View { - VStack(spacing: 3) { - moreLabel - verticalColorSquares - lessLabel - } - } - - @ViewBuilder - private var verticalColorSquares: some View { - ForEach(Array((0..<5).reversed()), id: \.self) { level in - LegendSquare( - level: level, - accessibility: accessibility - ) - } - } - - @ViewBuilder - private var compactLegend: some View { - HStack(spacing: 4) { - colorSquares - compactCostLabel - } - .accessibilityElement(children: .combine) - .accessibilityLabel(compactAccessibilityLabel) - } - - // MARK: - Components (Low Level) - - @ViewBuilder - private var legendTitle: some View { - if let title = effectiveTitle { - Text(title) - .font(font.weight(.semibold)) - .foregroundColor(.primary) - .accessibilityAddTraits(.isHeader) - } - } - - @ViewBuilder - private var lessLabel: some View { - if showIntensityLabels { - Text("Less") - .font(font) - .foregroundColor(.secondary) - } - } - - @ViewBuilder - private var moreLabel: some View { - if showIntensityLabels { - Text("More") - .font(font) - .foregroundColor(.secondary) - } - } - - @ViewBuilder - private var colorSquares: some View { - HStack(spacing: 3) { - ForEach(0..<5, id: \.self) { level in - LegendSquare( - level: level, - accessibility: accessibility - ) - } - } - } - - @ViewBuilder - private var conditionalCostReference: some View { - if showCostLabels && maxCost > 0 { - costReference - } - } - - @ViewBuilder - private var costReference: some View { - VStack(alignment: .trailing, spacing: 2) { - Text("Max: \(maxCost.asCurrency)") - .font(font) - .foregroundColor(.secondary) - - if maxCost > 1 { - let quarterCost = maxCost * 0.25 - Text("~\(quarterCost.asCurrency) per level") - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - } - } - - @ViewBuilder - private var compactCostLabel: some View { - if showCostLabels && maxCost > 0 { - Text("Max: \(maxCost.asCurrency)") - .font(.system(size: 9)) - .foregroundColor(.secondary) - } - } - - // MARK: - Computed Properties - - private var effectiveTitle: String? { - customTitle ?? (showCostLabels ? "Daily Cost Activity" : nil) - } - - // MARK: - Accessibility (Low Level) - - private var legendAccessibilityLabel: String { - guard accessibility.enableAccessibilityLabels else { return "" } - - var label = "Activity legend: " - label += "5 levels from no activity to high activity, " - - if showCostLabels && maxCost > 0 { - label += "maximum daily cost \(maxCost.asCurrency)" - } - - return label - } - - private var compactAccessibilityLabel: String { - guard accessibility.enableAccessibilityLabels else { return "" } - return "Activity scale with maximum cost \(maxCost.asCurrency)" - } -} - -// MARK: - Preview - -#if DEBUG -struct HeatmapLegend_Previews: PreviewProvider { - static var previews: some View { - VStack(spacing: 20) { - HeatmapLegend( - colorTheme: .github, - maxCost: 15.42, - style: .horizontal - ) - - HeatmapLegend( - colorTheme: .ocean, - maxCost: 8.75, - style: .compact - ) - - HeatmapLegend( - colorTheme: .sunset, - maxCost: 23.15, - style: .vertical - ) - - Spacer() - } - .padding() - .background(Color(.controlBackgroundColor)) - } -} -#endif diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift deleted file mode 100644 index 5aaea72..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// HeatmapLegendBuilder.swift -// Builder pattern for creating customized legends -// - -import SwiftUI - -// MARK: - Legend Builder - -/// Builder pattern for creating customized legends -public struct HeatmapLegendBuilder: @unchecked Sendable { - private var colorTheme: HeatmapColorTheme = .github - private var maxCost: Double = 0 - private var style: HeatmapLegend.LegendStyle = .horizontal - private var font: Font = .caption - private var showCostLabels: Bool = true - private var showIntensityLabels: Bool = true - private var customTitle: String? - private var accessibility: HeatmapAccessibility = .default - - public init() {} - - public func colorTheme(_ theme: HeatmapColorTheme) -> Self { - var builder = self - builder.colorTheme = theme - return builder - } - - public func maxCost(_ cost: Double) -> Self { - var builder = self - builder.maxCost = cost - return builder - } - - public func style(_ legendStyle: HeatmapLegend.LegendStyle) -> Self { - var builder = self - builder.style = legendStyle - return builder - } - - public func font(_ legendFont: Font) -> Self { - var builder = self - builder.font = legendFont - return builder - } - - public func showCostLabels(_ show: Bool) -> Self { - var builder = self - builder.showCostLabels = show - return builder - } - - public func showIntensityLabels(_ show: Bool) -> Self { - var builder = self - builder.showIntensityLabels = show - return builder - } - - public func title(_ title: String?) -> Self { - var builder = self - builder.customTitle = title - return builder - } - - public func accessibility(_ config: HeatmapAccessibility) -> Self { - var builder = self - builder.accessibility = config - return builder - } - - public func build() -> HeatmapLegend { - HeatmapLegend( - colorTheme: colorTheme, - maxCost: maxCost, - style: style, - font: font, - showCostLabels: showCostLabels, - showIntensityLabels: showIntensityLabels, - customTitle: customTitle, - accessibility: accessibility - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift deleted file mode 100644 index a1cc322..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// LegendSquare.swift -// Individual square component for legend visualization -// - -import SwiftUI - -// MARK: - Legend Square - -/// Individual square in the legend -struct LegendSquare: View { - let level: Int - let accessibility: HeatmapAccessibility - - @Environment(\.colorScheme) private var colorScheme - - private var squareColor: Color { - HeatmapColorScheme.color(for: level, scheme: colorScheme) - } - - var body: some View { - Rectangle() - .fill(squareColor) - .frame(width: 11, height: 11) - .cornerRadius(2) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.gray.opacity(0.3), lineWidth: 0.5) - ) - .accessibilityElement(children: .ignore) - .accessibilityLabel(accessibilityLabel) - .accessibilityValue(accessibilityValue) - } - - private var accessibilityLabel: String { - guard accessibility.enableAccessibilityLabels else { return "" } - return ActivityLevelLabels.label(for: level) - } - - private var accessibilityValue: String { - guard accessibility.enableAccessibilityValues else { return "" } - return "Level \(level) of 4" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift deleted file mode 100644 index a984d93..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// ActivityLevel.swift -// Activity level classification for tooltip display -// - -import SwiftUI - -// MARK: - Activity Level (Pure Data) - -/// Activity level classification based on intensity -enum ActivityLevel { - case none, low, medium, high, veryHigh - - init(intensity: Double) { - switch intensity { - case 0: self = .none - case ..<0.25: self = .low - case ..<0.5: self = .medium - case ..<0.75: self = .high - default: self = .veryHigh - } - } - - var text: String { - switch self { - case .none: "None" - case .low: "Low" - case .medium: "Medium" - case .high: "High" - case .veryHigh: "Very High" - } - } - - var color: Color { - switch self { - case .none: .gray - case .low: .green.opacity(0.7) - case .medium: .green - case .high: .orange - case .veryHigh: .red - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift deleted file mode 100644 index 40b2679..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// ActivityLevelLabels.swift -// Pure data for activity level accessibility labels -// - -import Foundation - -// MARK: - Activity Level Labels (Pure Data) - -enum ActivityLevelLabels { - static let labels = [ - "No activity", - "Low activity", - "Medium-low activity", - "Medium-high activity", - "High activity" - ] - - static func label(for level: Int) -> String { - labels.indices.contains(level) ? labels[level] : "Activity level \(level)" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift deleted file mode 100644 index 0355fe0..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// BorderStyle.swift -// Pure data struct for day square border styling -// - -import SwiftUI - -// MARK: - Border Style (Pure Data) - -struct BorderStyle { - let color: Color - let width: CGFloat - - static let none = BorderStyle(color: .clear, width: 0) - - static func forDay(_ day: HeatmapDay, isHovered: Bool, config: HeatmapConfiguration) -> BorderStyle { - if day.isToday { - return BorderStyle(color: config.todayHighlightColor, width: config.todayHighlightWidth) - } else if isHovered { - return BorderStyle(color: .primary, width: 1) - } else { - return .none - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift deleted file mode 100644 index 8766298..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift +++ /dev/null @@ -1,352 +0,0 @@ -// -// ColorScheme.swift -// Advanced color management for heatmap visualization -// -// Provides sophisticated color calculations, theme management, and performance-optimized -// color caching for heatmap components with accessibility support. -// - -import SwiftUI -import Foundation - -// MARK: - Color Manager - -/// Advanced color management for heatmap visualizations -public final class HeatmapColorManager: @unchecked Sendable { - - // MARK: - Singleton - - public static let shared = HeatmapColorManager() - private init() {} - - // MARK: - Types - - /// Different color variations for special states - public enum ColorVariation: String, CaseIterable { - case normal = "normal" - case hovered = "hovered" - case selected = "selected" - case dimmed = "dimmed" - case highlighted = "highlighted" - } - - // MARK: - Public Interface (High Level) - - /// Get color for a cost value with caching - public func color( - for cost: Double, - maxCost: Double, - theme: HeatmapColorTheme = .github, - variation: ColorVariation = .normal - ) -> Color { - cachedColor(for: cost, maxCost: maxCost, theme: theme, variation: variation) - ?? computeAndCacheColor(for: cost, maxCost: maxCost, theme: theme, variation: variation) - } - - /// Pre-calculate colors for a range of values (performance optimization) - public func preCalculateColors( - for costs: [Double], - maxCost: Double, - theme: HeatmapColorTheme = .github - ) -> [Double: Color] { - costs.reduce(into: [:]) { colorMap, cost in - colorMap[cost] = color(for: cost, maxCost: maxCost, theme: theme) - } - } - - /// Analyze color distribution for a dataset - public func analyzeColorDistribution(costs: [Double], maxCost: Double) -> ColorDistribution { - let levelCounts = countCostsByLevel(costs, maxCost: maxCost) - let averageCost = calculateAverageCost(costs) - - return ColorDistribution( - totalItems: costs.count, - levelCounts: levelCounts, - maxCost: maxCost, - averageCost: averageCost - ) - } - - /// Clear color cache to free memory - public func clearCache() { - colorCache.removeAll() - } - - // MARK: - Color Calculation (Mid Level) - - private func computeAndCacheColor( - for cost: Double, - maxCost: Double, - theme: HeatmapColorTheme, - variation: ColorVariation - ) -> Color { - let baseColor = calculateBaseColor(cost: cost, maxCost: maxCost, theme: theme) - let finalColor = applyVariation(baseColor, variation: variation) - - storeInCache(finalColor, for: cost, maxCost: maxCost, theme: theme, variation: variation) - - return finalColor - } - - private func calculateBaseColor(cost: Double, maxCost: Double, theme: HeatmapColorTheme) -> Color { - if cost == 0 { return theme.colors[0] } - - let intensity = calculateIntensity(cost: cost, maxCost: maxCost) - let levelIndex = IntensityLevel.fromIntensity(intensity) - - return theme.colors[levelIndex] - } - - private func calculateIntensity(cost: Double, maxCost: Double) -> Double { - maxCost > 0 ? min(cost / maxCost, 1.0) : 0.0 - } - - private func countCostsByLevel(_ costs: [Double], maxCost: Double) -> [Int] { - costs.reduce(into: [0, 0, 0, 0, 0]) { counts, cost in - let intensity = calculateIntensity(cost: cost, maxCost: maxCost) - let level = IntensityLevel.fromIntensity(intensity) - counts[level] += 1 - } - } - - private func calculateAverageCost(_ costs: [Double]) -> Double { - costs.isEmpty ? 0 : costs.reduce(0, +) / Double(costs.count) - } - - // MARK: - Caching (Infrastructure) - - private var colorCache: [ColorCacheKey: Color] = [:] - - private struct ColorCacheKey: Hashable { - let cost: Double - let maxCost: Double - let theme: HeatmapColorTheme - let variation: ColorVariation - - func hash(into hasher: inout Hasher) { - hasher.combine(cost) - hasher.combine(maxCost) - hasher.combine(theme.rawValue) - hasher.combine(variation.rawValue) - } - } - - private func cachedColor( - for cost: Double, - maxCost: Double, - theme: HeatmapColorTheme, - variation: ColorVariation - ) -> Color? { - let cacheKey = ColorCacheKey(cost: cost, maxCost: maxCost, theme: theme, variation: variation) - return colorCache[cacheKey] - } - - private func storeInCache( - _ color: Color, - for cost: Double, - maxCost: Double, - theme: HeatmapColorTheme, - variation: ColorVariation - ) { - let cacheKey = ColorCacheKey(cost: cost, maxCost: maxCost, theme: theme, variation: variation) - colorCache[cacheKey] = color - } - - // MARK: - Color Variations (Low Level) - - private func applyVariation(_ baseColor: Color, variation: ColorVariation) -> Color { - switch variation { - case .normal: - return baseColor - case .hovered: - return baseColor.opacity(0.8) - case .selected: - return baseColor.brightness(0.2) - case .dimmed: - return baseColor.opacity(0.5) - case .highlighted: - return baseColor.saturation(1.3) - } - } -} - -// MARK: - Color Distribution - -/// Statistics about color distribution in a heatmap -public struct ColorDistribution: Sendable { - public let totalItems: Int - public let levelCounts: [Int] // Count for each of the 5 color levels - public let maxCost: Double - public let averageCost: Double - - /// Percentage distribution across levels - public var levelPercentages: [Double] { - guard totalItems > 0 else { return Array(repeating: 0, count: 5) } - return levelCounts.map { Double($0) / Double(totalItems) * 100 } - } - - /// Most common color level - public var dominantLevel: Int { - guard let maxIndex = levelCounts.enumerated().max(by: { $0.element < $1.element })?.offset else { - return 0 - } - return maxIndex - } -} - -// MARK: - Accessibility Colors - -/// Accessibility-friendly color management -public struct AccessibilityColorScheme { - - /// High contrast color scheme for better accessibility - public static let highContrast = HeatmapColorTheme.custom(colors: [ - Color.black.opacity(0.1), // Empty - very light - Color.blue.opacity(0.4), // Low - medium blue - Color.blue.opacity(0.6), // Medium-low - darker blue - Color.blue.opacity(0.8), // Medium-high - dark blue - Color.blue // High - full blue - ]) - - /// Colorblind-friendly color scheme (safe for deuteranopia/protanopia) - public static let colorblindFriendly = HeatmapColorTheme.custom(colors: [ - Color.gray.opacity(0.3), // Empty - Color.blue.opacity(0.3), // Low - blue instead of green - Color.blue.opacity(0.5), // Medium-low - Color.blue.opacity(0.7), // Medium-high - Color.blue // High - ]) - - /// Monochrome scheme for ultimate accessibility - public static let monochrome = HeatmapColorTheme.custom(colors: [ - Color(white: 0.9), // Empty - very light gray - Color(white: 0.7), // Low - Color(white: 0.5), // Medium-low - Color(white: 0.3), // Medium-high - Color(white: 0.1) // High - very dark gray - ]) -} - -// MARK: - Dynamic Color Themes - -public extension HeatmapColorTheme { - - /// Create a custom color theme - /// - Parameter colors: Array of 5 colors for the theme levels - /// - Returns: Custom color theme - static func custom(colors: [Color]) -> HeatmapColorTheme { - // This would require modifying the enum to support custom themes - // For now, we'll return a default theme - return .github - } - - /// Generate a color theme based on a base color - /// - Parameter baseColor: The primary color to base the theme on - /// - Returns: Generated color theme - static func generate(from baseColor: Color) -> [Color] { - return [ - Color(red: 240/255, green: 242/255, blue: 245/255), // Empty - baseColor.opacity(0.35), // Low - baseColor.opacity(0.55), // Medium-low - baseColor.opacity(0.75), // Medium-high - baseColor.opacity(0.95) // High - ] - } - - /// Check if a color theme provides sufficient contrast - /// - Parameter theme: The theme to check - /// - Returns: True if the theme has good contrast - func hasGoodContrast() -> Bool { - // Simplified contrast check - in a real implementation, - // you would calculate WCAG contrast ratios - let colors = self.colors - guard colors.count >= 2 else { return false } - - // Check that there's sufficient difference between empty and high - // This is a simplified check - proper implementation would use luminance - return true - } -} - -// MARK: - Performance Optimizations - -/// Performance-optimized color utilities -public struct HeatmapColorPerformance: Sendable { - - /// Pre-computed color lookup table for common cost ranges - public static func buildColorLUT( - maxCost: Double, - steps: Int = 100, - theme: HeatmapColorTheme = .github - ) -> [Color] { - (0...steps).map { i in - let cost = Double(i) / Double(steps) * maxCost - return HeatmapColorManager.shared.color(for: cost, maxCost: maxCost, theme: theme) - } - } - - /// Get color from lookup table for performance - /// - Parameters: - /// - cost: Cost value - /// - maxCost: Maximum cost - /// - lut: Pre-computed lookup table - /// - Returns: Color from lookup table - public static func colorFromLUT( - cost: Double, - maxCost: Double, - lut: [Color] - ) -> Color { - guard maxCost > 0, !lut.isEmpty else { return HeatmapColorTheme.github.colors[0] } - - let normalizedCost = min(cost / maxCost, 1.0) - let index = Int(normalizedCost * Double(lut.count - 1)) - let clampedIndex = max(0, min(lut.count - 1, index)) - - return lut[clampedIndex] - } -} - -// MARK: - Color Utilities - -public extension Color { - - /// Adjust brightness of a color - /// - Parameter amount: Brightness adjustment (-1.0 to 1.0) - /// - Returns: Color with adjusted brightness - func brightness(_ amount: Double) -> Color { - // This is a simplified implementation - // Real implementation would convert to HSB, adjust, and convert back - return self.opacity(max(0, min(1, 1.0 + amount))) - } - - /// Adjust saturation of a color - /// - Parameter amount: Saturation multiplier - /// - Returns: Color with adjusted saturation - func saturation(_ amount: Double) -> Color { - // Simplified implementation - return self - } - - /// Get hex representation of color (useful for debugging) - var hexString: String { - // Simplified - real implementation would extract RGB components - return "#000000" - } -} - -// MARK: - Intensity Level Calculation (Pure Functions) - -private enum IntensityLevel { - /// Intensity thresholds for each level - static let thresholds: [(range: PartialRangeFrom, level: Int)] = [ - (0.75..., 4), // High - (0.5..., 3), // Medium-high - (0.25..., 2), // Medium-low - (0.0..., 1) // Low (anything > 0) - ] - - /// Convert intensity (0.0-1.0) to discrete level (0-4) - static func fromIntensity(_ intensity: Double) -> Int { - intensity == 0 ? 0 : (thresholds.first { $0.range.contains(intensity) }?.level ?? 1) - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift deleted file mode 100644 index c129b94..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateConstants.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// DateConstants.swift -// Constants for heatmap date calculations -// - -import Foundation - -enum DateConstants { - static let daysPerWeek = 7 - static let defaultRollingDays = 365 - static let maxDaysForPerformance = 400 - static let minDaysRequired = 1 -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift deleted file mode 100644 index e337057..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// DateRangeValidation.swift -// Date range validation and utilities -// - -import Foundation - -// MARK: - Validation Rules - -enum DateRangeValidation { - - /// All validation rules as closures that return optional error message - static func rules(calendar: Calendar) -> [(Date, Date) -> String?] { - [ - { start, end in - start > end ? "Start date cannot be after end date" : nil - }, - { start, end in - let days = calendar.dateComponents([.day], from: start, to: end).day ?? 0 - return days > DateConstants.maxDaysForPerformance - ? "Date range too large (maximum \(DateConstants.maxDaysForPerformance) days for performance)" - : nil - }, - { start, end in - let days = calendar.dateComponents([.day], from: start, to: end).day ?? 0 - return days < DateConstants.minDaysRequired - ? "Date range too small (minimum \(DateConstants.minDaysRequired) day)" - : nil - }, - { start, _ in - let oneYearFromNow = calendar.date(byAdding: .year, value: 1, to: Date())! - return start > oneYearFromNow ? "Start date is too far in the future" : nil - } - ] - } - - /// Validate date range and return all errors - static func validate(start: Date, end: Date, calendar: Calendar) -> [String] { - rules(calendar: calendar).compactMap { $0(start, end) } - } -} - -// MARK: - Date Range Extensions - -public extension ClosedRange where Bound == Date { - - /// Whether the range contains today - var containsToday: Bool { - let today = Calendar.current.startOfDay(for: Date()) - return self.contains(today) - } - - /// Number of days in the range - var dayCount: Int { - let calendar = Calendar.current - let startDay = calendar.startOfDay(for: lowerBound) - let endDay = calendar.startOfDay(for: upperBound) - let components = calendar.dateComponents([.day], from: startDay, to: endDay) - return (components.day ?? 0) + 1 - } - - /// Array of all dates in the range - var allDates: [Date] { - HeatmapDateCalculator.shared.dateSequence(from: lowerBound, to: upperBound) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift deleted file mode 100644 index 09eaa57..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift +++ /dev/null @@ -1,347 +0,0 @@ -// -// HeatmapData.swift -// Data models for heatmap visualization -// -// Provides type-safe data structures for representing heatmap information -// with optimized caching and pre-computed values for performance. -// - -import SwiftUI -import Foundation - -// MARK: - Core Data Models - -/// Represents a single day in the heatmap with pre-computed display values for performance -public struct HeatmapDay: Identifiable, Equatable, Hashable { - - // MARK: - Identification & Core Data - - /// Stable date-based identifier to prevent SwiftUI view recreation - public let id: String - - /// The date this day represents - public let date: Date - - /// The cost value for this day - public let cost: Double - - // MARK: - Calendar Properties - - /// Day of the year (1-366) - public let dayOfYear: Int - - /// Week number within the heatmap - public let weekOfYear: Int - - /// Day of the week (0=Sunday, 6=Saturday) - public let dayOfWeek: Int - - // MARK: - Display Properties - - /// Whether this day has no usage (cost == 0) - public let isEmpty: Bool - - /// Whether this day is today - public let isToday: Bool - - /// Pre-formatted date string for display (e.g., "Jan 15, 2024") - public let dateString: String - - /// Pre-formatted cost string for display (e.g., "$1.23" or "No usage") - public let costString: String - - /// Pre-computed color for light mode (legacy - use color(for:) for scheme-aware color) - public let color: Color - - /// Intensity level (0.0 to 1.0) relative to the maximum cost - public let intensity: Double - - /// Discrete intensity level (0-4) for color lookup - public let intensityLevel: Int - - // MARK: - Initialization - - public init( - date: Date, - cost: Double, - dayOfYear: Int, - weekOfYear: Int, - dayOfWeek: Int, - maxCost: Double - ) { - self.id = HeatmapDateFormatter.stableIdentifier(for: date) - self.date = date - self.cost = cost - self.dayOfYear = dayOfYear - self.weekOfYear = weekOfYear - self.dayOfWeek = dayOfWeek - - self.isEmpty = cost == 0 - self.isToday = HeatmapDateFormatter.isToday(date) - self.dateString = HeatmapDateFormatter.displayString(for: date) - self.costString = cost > 0 ? cost.asCurrency : "No usage" - - self.intensity = IntensityLevelCalculator.intensity(cost: cost, maxCost: maxCost) - self.intensityLevel = IntensityLevelCalculator.level(for: self.intensity) - self.color = HeatmapColorScheme.color(for: cost, maxCost: maxCost) - } - - // MARK: - Color Scheme Support - - /// Returns the appropriate color for the given color scheme - public func color(for scheme: ColorScheme) -> Color { - HeatmapColorScheme.color(for: intensityLevel, scheme: scheme) - } - - // MARK: - Equatable & Hashable - - public static func == (lhs: HeatmapDay, rhs: HeatmapDay) -> Bool { - lhs.id == rhs.id && lhs.cost == rhs.cost - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(cost) - } -} - -/// Represents a week in the heatmap containing up to 7 days -public struct HeatmapWeek: Identifiable, Equatable { - - /// Stable week-based identifier - public let id: String - - /// Week number within the heatmap - public let weekNumber: Int - - /// Array of 7 days (some may be nil for partial weeks) - public let days: [HeatmapDay?] - - /// Total cost for this week - public let totalCost: Double - - /// Number of days with usage in this week - public let daysWithUsage: Int - - public init(weekNumber: Int, days: [HeatmapDay?]) { - self.id = "week-\(weekNumber)" - self.weekNumber = weekNumber - self.days = days - - // Calculate derived properties - self.totalCost = days.compactMap { $0?.cost }.reduce(0, +) - self.daysWithUsage = days.compactMap { $0 }.filter { !$0.isEmpty }.count - } -} - -/// Represents a month label in the heatmap -public struct HeatmapMonth: Identifiable, Equatable { - - /// Stable month-based identifier - public let id: String - - /// Short month name (e.g., "Jan", "Feb") - public let name: String - - /// Range of week indices this month spans - public let weekSpan: Range - - /// Full month name (e.g., "January", "February") - public let fullName: String - - /// Month number (1-12) - public let monthNumber: Int - - /// Year this month belongs to - public let year: Int - - public init(name: String, weekSpan: Range, fullName: String = "", monthNumber: Int = 0, year: Int = 0) { - self.id = "month-\(name)-\(year)" - self.name = name - self.weekSpan = weekSpan - self.fullName = fullName.isEmpty ? name : fullName - self.monthNumber = monthNumber - self.year = year - } -} - -/// Complete dataset for heatmap visualization -public struct HeatmapDataset: Equatable { - - /// Array of weeks containing the heatmap data - public let weeks: [HeatmapWeek] - - /// Array of month labels for the header - public let monthLabels: [HeatmapMonth] - - /// Maximum cost value across all days (used for color scaling) - public let maxCost: Double - - /// Date range covered by this dataset - public let dateRange: ClosedRange - - /// Total cost across all days - public let totalCost: Double - - /// Total number of days with usage - public let daysWithUsage: Int - - /// All days flattened from weeks (excluding nil days) - public var allDays: [HeatmapDay] { - weeks.flatMap { $0.days.compactMap { $0 } } - } - - public init( - weeks: [HeatmapWeek], - monthLabels: [HeatmapMonth], - maxCost: Double, - dateRange: ClosedRange - ) { - self.weeks = weeks - self.monthLabels = monthLabels - self.maxCost = maxCost - self.dateRange = dateRange - - // Calculate derived properties - let allDays = weeks.flatMap { $0.days.compactMap { $0 } } - self.totalCost = allDays.reduce(0) { $0 + $1.cost } - self.daysWithUsage = allDays.filter { !$0.isEmpty }.count - } -} - -// MARK: - Supporting Types - -// MARK: Date Formatting Helpers - -private enum HeatmapDateFormatter { - private static let idFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - private static let displayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d, yyyy" - return formatter - }() - - static func stableIdentifier(for date: Date) -> String { - idFormatter.string(from: date) - } - - static func displayString(for date: Date) -> String { - displayFormatter.string(from: date) - } - - static func isToday(_ date: Date) -> Bool { - Calendar.current.isDateInToday(date) - } -} - -// MARK: Intensity Level Calculation (Pure Functions) - -private enum IntensityLevelCalculator { - /// Thresholds for each level (checked in order) - static let thresholds: [(range: PartialRangeFrom, level: Int)] = [ - (0.75..., 4), // High - (0.5..., 3), // Medium-high - (0.25..., 2), // Medium-low - (0.0..., 1) // Low (anything > 0) - ] - - /// Convert intensity (0.0-1.0) to discrete level (0-4) - static func level(for intensity: Double) -> Int { - intensity == 0 ? 0 : (thresholds.first { $0.range.contains(intensity) }?.level ?? 1) - } - - /// Calculate intensity from cost - static func intensity(cost: Double, maxCost: Double) -> Double { - maxCost > 0 ? min(cost / maxCost, 1.0) : 0.0 - } -} - -// MARK: Color Scheme - -/// Optimized color scheme for heatmap visualization with light/dark mode support -public enum HeatmapColorScheme { - - // MARK: - Light Mode Colors (GitHub Light Theme) - - /// Colors for light mode (5 levels from no activity to high activity) - public static let lightColors: [Color] = [ - Color(red: 235/255, green: 237/255, blue: 240/255), // Level 0: #ebedf0 - Color(red: 155/255, green: 233/255, blue: 168/255), // Level 1: #9be9a8 - Color(red: 64/255, green: 196/255, blue: 99/255), // Level 2: #40c463 - Color(red: 48/255, green: 161/255, blue: 78/255), // Level 3: #30a14e - Color(red: 33/255, green: 110/255, blue: 57/255) // Level 4: #216e39 - ] - - // MARK: - Dark Mode Colors (GitHub Dark Theme) - - /// Colors for dark mode (5 levels from no activity to high activity) - public static let darkColors: [Color] = [ - Color(red: 22/255, green: 27/255, blue: 34/255), // Level 0: #161b22 - Color(red: 14/255, green: 68/255, blue: 41/255), // Level 1: #0e4429 - Color(red: 0/255, green: 109/255, blue: 50/255), // Level 2: #006d32 - Color(red: 38/255, green: 166/255, blue: 65/255), // Level 3: #26a641 - Color(red: 57/255, green: 211/255, blue: 83/255) // Level 4: #39d353 - ] - - // MARK: - Legacy Support - - /// Legacy color array (light mode) - kept for backward compatibility - @available(*, deprecated, message: "Use colors(for:) with ColorScheme instead") - public static var legendColors: [Color] { lightColors } - - // MARK: - Color Calculation - - /// Returns colors for the specified color scheme - public static func colors(for scheme: ColorScheme) -> [Color] { - scheme == .dark ? darkColors : lightColors - } - - /// Returns the appropriate color for a given level and color scheme - public static func color(for level: Int, scheme: ColorScheme) -> Color { - let colors = colors(for: scheme) - let index = max(0, min(colors.count - 1, level)) - return colors[index] - } - - /// Returns the appropriate color for a given cost value (light mode - legacy) - public static func color(for cost: Double, maxCost: Double) -> Color { - let level = intensityLevel(for: cost, maxCost: maxCost) - return lightColors[level] - } - - /// Returns the appropriate color for a given cost value and color scheme - public static func color(for cost: Double, maxCost: Double, scheme: ColorScheme) -> Color { - let level = intensityLevel(for: cost, maxCost: maxCost) - return color(for: level, scheme: scheme) - } - - /// Returns the intensity level (0-4) for legend purposes - public static func intensityLevel(for cost: Double, maxCost: Double) -> Int { - let intensity = IntensityLevelCalculator.intensity(cost: cost, maxCost: maxCost) - return IntensityLevelCalculator.level(for: intensity) - } -} - -// MARK: - Extensions - -/// Safe array access extension -public extension Array { - subscript(safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil - } -} - -/// Double extension for currency formatting -public extension Double { - var asCurrency: String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency - formatter.locale = Locale.current - return formatter.string(from: NSNumber(value: self)) ?? "$0.00" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift deleted file mode 100644 index a98c554..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift +++ /dev/null @@ -1,301 +0,0 @@ -// -// HeatmapDateCalculator.swift -// Main calculator for heatmap date operations -// - -import Foundation - -/// Utility class for date calculations in heatmap generation -public final class HeatmapDateCalculator: @unchecked Sendable { - - // MARK: - Singleton - - public static let shared = HeatmapDateCalculator() - private init() {} - - // MARK: - Cached Formatters - - private lazy var idFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - formatter.timeZone = TimeZone.current - formatter.locale = Locale.current - return formatter - }() - - private lazy var displayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d, yyyy" - formatter.timeZone = TimeZone.current - formatter.locale = Locale.current - return formatter - }() - - private lazy var calendar: Calendar = { - var cal = Calendar.current - cal.timeZone = TimeZone.current - return cal - }() - - // MARK: - Public Types - - /// Information about a month for labeling - public struct MonthInfo { - public let name: String - public let fullName: String - public let monthNumber: Int - public let year: Int - public let firstWeek: Int - public let lastWeek: Int - - public var weekSpan: Range { - firstWeek..<(lastWeek + 1) - } - } - - // MARK: - Public Interface (High Level) - - /// Rolling date range ending on given date - public func rollingDateRange( - endingOn endDate: Date = Date(), - numberOfDays: Int = 365 - ) -> (start: Date, end: Date) { - let end = calendar.startOfDay(for: endDate) - let start = calendar.date(byAdding: .day, value: -(numberOfDays - 1), to: end)! - return (start: start, end: end) - } - - /// Rolling date range with complete weeks, avoiding duplicate months - public func rollingDateRangeWithCompleteWeeks( - endingOn endDate: Date = Date(), - numberOfDays: Int = 365 - ) -> (start: Date, end: Date) { - let end = calendar.startOfDay(for: endDate) - let initialStart = calendar.date(byAdding: .day, value: -(numberOfDays - 1), to: end)! - let adjustedStart = MonthOps.adjustStartForSameMonth(start: initialStart, end: end, calendar: calendar) - let finalStart = WeekOps.adjustToCompleteWeek(adjustedStart, calendar: calendar) - return (start: finalStart, end: end) - } - - /// Sunday of the week containing given date - public func weekStart(for date: Date) -> Date { - WeekOps.weekStart(for: date, calendar: calendar) - } - - /// Number of weeks in date range - public func weeksInRange(from startDate: Date, to endDate: Date) -> Int { - let start = weekStart(for: startDate) - let daysBetween = calendar.dateComponents([.day], from: start, to: endDate).day ?? 0 - return (daysBetween / DateConstants.daysPerWeek) + 1 - } - - /// Calendar properties for a date - public func calendarProperties(for date: Date) -> (dayOfYear: Int, weekOfYear: Int, dayOfWeek: Int) { - ( - dayOfYear: calendar.ordinality(of: .day, in: .year, for: date) ?? 0, - weekOfYear: calendar.component(.weekOfYear, from: date), - dayOfWeek: calendar.component(.weekday, from: date) - 1 - ) - } - - /// Check if date is today - public func isToday(_ date: Date) -> Bool { - calendar.startOfDay(for: date) == calendar.startOfDay(for: Date()) - } - - /// Format date as ID string (yyyy-MM-dd) - public func formatDateAsID(_ date: Date) -> String { - idFormatter.string(from: date) - } - - /// Format date for display (MMM d, yyyy) - public func formatDateForDisplay(_ date: Date) -> String { - displayFormatter.string(from: date) - } - - /// Generate month labels for date range - public func generateMonthLabels(from startDate: Date, to endDate: Date) -> [MonthInfo] { - var months: [MonthInfo] = [] - var state = createInitialMonthTrackingState(for: startDate) - - enumerateWeeks(from: startDate, to: endDate) { weekIndex, weekStart in - processWeekForMonthTransition( - weekIndex: weekIndex, - weekStart: weekStart, - dateRange: startDate...endDate, - state: &state, - months: &months - ) - } - - finalizeMonthLabels(state: state, months: &months) - return months - } - - /// Generate sequence of dates between two dates - public func dateSequence(from startDate: Date, to endDate: Date) -> [Date] { - let start = calendar.startOfDay(for: startDate) - let end = calendar.startOfDay(for: endDate) - - return Array( - sequence(first: start) { self.calendar.date(byAdding: .day, value: 1, to: $0) } - .prefix { $0 <= end } - ) - } - - /// Generate weeks layout for heatmap - public func generateWeeksLayout(from startDate: Date, to endDate: Date) -> [[Date?]] { - let firstWeek = findFirstCompleteWeekStart(for: startDate) - return buildWeeksArray(from: firstWeek, to: endDate) - } - - // MARK: - Month Label Generation (Mid Level) - - private struct MonthTrackingState { - var currentMonth: Int - var currentYear: Int - var monthStartWeek: Int - var weekIndex: Int - } - - private func createInitialMonthTrackingState(for startDate: Date) -> MonthTrackingState { - MonthTrackingState( - currentMonth: calendar.component(.month, from: startDate), - currentYear: calendar.component(.year, from: startDate), - monthStartWeek: 0, - weekIndex: 0 - ) - } - - private func enumerateWeeks( - from startDate: Date, - to endDate: Date, - handler: (Int, Date) -> Void - ) { - WeekOps.weekSequence(from: startDate, calendar: calendar) - .prefix { $0 <= endDate } - .enumerated() - .forEach { handler($0.offset, $0.element) } - } - - private func processWeekForMonthTransition( - weekIndex: Int, - weekStart: Date, - dateRange: ClosedRange, - state: inout MonthTrackingState, - months: inout [MonthInfo] - ) { - state.weekIndex = weekIndex - - guard let firstVisibleDay = WeekOps.firstVisibleDay(in: weekStart, within: dateRange, calendar: calendar) else { - return - } - - let dayMonth = calendar.component(.month, from: firstVisibleDay) - let dayYear = calendar.component(.year, from: firstVisibleDay) - - if MonthOps.hasMonthChanged(from: (state.currentMonth, state.currentYear), to: (dayMonth, dayYear)) { - appendCompletedMonth(state: state, to: &months) - state.currentMonth = dayMonth - state.currentYear = dayYear - state.monthStartWeek = weekIndex - } - } - - private func finalizeMonthLabels(state: MonthTrackingState, months: inout [MonthInfo]) { - let finalWeekIndex = state.weekIndex + 1 - guard finalWeekIndex > state.monthStartWeek else { return } - - let monthAbbrev = MonthOps.abbreviatedName(for: state.currentMonth, calendar: calendar) - let isDuplicateOfFirst = months.first?.name == monthAbbrev && - months.first?.monthNumber == state.currentMonth - - if isDuplicateOfFirst { - extendFirstMonthToIncludeFinalWeeks(finalWeekIndex: finalWeekIndex - 1, months: &months) - } else { - appendFinalMonth(state: state, lastWeek: finalWeekIndex - 1, to: &months) - } - } - - // MARK: - Week Layout Generation (Mid Level) - - private func findFirstCompleteWeekStart(for startDate: Date) -> Date { - let weekStartDate = WeekOps.weekStart(for: startDate, calendar: calendar) - - if WeekOps.isPartialWeek(weekStart: weekStartDate, rangeStart: startDate, calendar: calendar) { - return calendar.date(byAdding: .weekOfYear, value: 1, to: weekStartDate)! - } - return weekStartDate - } - - private func buildWeeksArray(from weekStart: Date, to endDate: Date) -> [[Date?]] { - Array( - WeekOps.weekSequence(from: weekStart, calendar: calendar) - .prefix { $0 <= endDate } - .map { WeekOps.weekDays(from: $0, calendar: calendar) } - ) - } - - // MARK: - Month Info Construction (Low Level) - - private func appendCompletedMonth(state: MonthTrackingState, to months: inout [MonthInfo]) { - guard !months.isEmpty || state.weekIndex > 0 else { return } - - months.append(createMonthInfo( - month: state.currentMonth, - year: state.currentYear, - firstWeek: state.monthStartWeek, - lastWeek: state.weekIndex - 1 - )) - } - - private func appendFinalMonth(state: MonthTrackingState, lastWeek: Int, to months: inout [MonthInfo]) { - months.append(createMonthInfo( - month: state.currentMonth, - year: state.currentYear, - firstWeek: state.monthStartWeek, - lastWeek: lastWeek - )) - } - - private func extendFirstMonthToIncludeFinalWeeks(finalWeekIndex: Int, months: inout [MonthInfo]) { - guard let firstMonth = months.first else { return } - - months[0] = MonthInfo( - name: firstMonth.name, - fullName: firstMonth.fullName, - monthNumber: firstMonth.monthNumber, - year: firstMonth.year, - firstWeek: firstMonth.firstWeek, - lastWeek: finalWeekIndex - ) - } - - private func createMonthInfo(month: Int, year: Int, firstWeek: Int, lastWeek: Int) -> MonthInfo { - MonthInfo( - name: MonthOps.abbreviatedName(for: month, calendar: calendar), - fullName: MonthOps.fullName(for: month, calendar: calendar), - monthNumber: month, - year: year, - firstWeek: firstWeek, - lastWeek: lastWeek - ) - } -} - -// MARK: - Date Validation - -public extension HeatmapDateCalculator { - - /// Validates that a date range is suitable for heatmap display - func validateDateRange(startDate: Date, endDate: Date) -> [String] { - var cal = Calendar.current - cal.timeZone = TimeZone.current - return DateRangeValidation.validate(start: startDate, end: endDate, calendar: cal) - } - - /// Whether a date range is valid for heatmap display - func isValidDateRange(startDate: Date, endDate: Date) -> Bool { - validateDateRange(startDate: startDate, endDate: endDate).isEmpty - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift deleted file mode 100644 index 156ba43..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// MonthOperations.swift -// Pure functions for month-based date calculations -// - -import Foundation - -enum MonthOps { - - /// Check if month/year combination has changed - static func hasMonthChanged( - from current: (month: Int, year: Int), - to new: (month: Int, year: Int) - ) -> Bool { - new.month != current.month || new.year != current.year - } - - /// Get abbreviated month name (3 chars) - static func abbreviatedName(for month: Int, calendar: Calendar) -> String { - String(calendar.monthSymbols[month - 1].prefix(3)) - } - - /// Get full month name - static func fullName(for month: Int, calendar: Calendar) -> String { - calendar.monthSymbols[month - 1] - } - - /// Adjust start date to next month if start and end are in same month - static func adjustStartForSameMonth(start: Date, end: Date, calendar: Calendar) -> Date { - let startMonth = calendar.component(.month, from: start) - let endMonth = calendar.component(.month, from: end) - - guard startMonth == endMonth else { return start } - - var components = calendar.dateComponents([.year, .month], from: start) - components.month! += 1 - components.day = 1 - return calendar.date(from: components)! - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift deleted file mode 100644 index d93c756..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// WeekOperations.swift -// Pure functions for week-based date calculations -// - -import Foundation - -enum WeekOps { - - /// Find Sunday of the week containing the given date - static func weekStart(for date: Date, calendar: Calendar) -> Date { - let startOfDay = calendar.startOfDay(for: date) - let weekday = calendar.component(.weekday, from: startOfDay) - let daysToSunday = weekday - 1 - return calendar.date(byAdding: .day, value: -daysToSunday, to: startOfDay)! - } - - /// Check if week start is before range start (partial week) - static func isPartialWeek(weekStart: Date, rangeStart: Date, calendar: Calendar) -> Bool { - guard weekStart < rangeStart else { return false } - let daysBefore = calendar.dateComponents([.day], from: weekStart, to: rangeStart).day ?? 0 - return daysBefore > 0 - } - - /// Advance to next complete week if current is partial - static func adjustToCompleteWeek(_ date: Date, calendar: Calendar) -> Date { - let weekStartDate = weekStart(for: date, calendar: calendar) - if weekStartDate < date { - return calendar.date(byAdding: .weekOfYear, value: 1, to: weekStartDate)! - } - return weekStartDate - } - - /// Generate sequence of week start dates - static func weekSequence(from startDate: Date, calendar: Calendar) -> UnfoldFirstSequence { - let firstWeek = weekStart(for: startDate, calendar: calendar) - return sequence(first: firstWeek) { current in - calendar.date(byAdding: .weekOfYear, value: 1, to: current) - } - } - - /// Build array of 7 dates for a week - static func weekDays(from weekStart: Date, calendar: Calendar) -> [Date?] { - (0.., calendar: Calendar) -> Date? { - (0.. HeatmapDataset { - let weeks = await generateWeeksData( - from: dateRange.start, - to: dateRange.end, - dailyUsage: dailyUsage - ) - - return HeatmapDataset( - weeks: weeks, - monthLabels: generateMonthLabels(from: dateRange.start, to: dateRange.end), - maxCost: calculateMaxCost(from: dailyUsage), - dateRange: dateRange.start...dateRange.end - ) - } - - func generateWeeksData( - from startDate: Date, - to endDate: Date, - dailyUsage: [DailyUsage] - ) async -> [HeatmapWeek] { - let costLookup = buildCostLookup(from: dailyUsage) - let maxCost = calculateMaxCost(from: dailyUsage) - let weeksLayout = dateCalculator.generateWeeksLayout(from: startDate, to: endDate) - - return buildWeeks( - from: weeksLayout, - costLookup: costLookup, - maxCost: maxCost, - dateRange: startDate...endDate - ) - } - - func generateMonthLabels(from startDate: Date, to endDate: Date) -> [HeatmapMonth] { - dateCalculator - .generateMonthLabels(from: startDate, to: endDate) - .map(makeHeatmapMonth) - } -} - -// MARK: - Week Building - -extension HeatmapStore { - - func buildWeeks( - from weeksLayout: [[Date?]], - costLookup: [String: Double], - maxCost: Double, - dateRange: ClosedRange - ) -> [HeatmapWeek] { - weeksLayout.enumerated().map { weekIndex, weekDates in - HeatmapWeek( - weekNumber: weekIndex, - days: buildWeekDays( - from: weekDates, - weekIndex: weekIndex, - costLookup: costLookup, - maxCost: maxCost, - dateRange: dateRange - ) - ) - } - } - - func buildWeekDays( - from weekDates: [Date?], - weekIndex: Int, - costLookup: [String: Double], - maxCost: Double, - dateRange: ClosedRange - ) -> [HeatmapDay?] { - weekDates.enumerated().map { dayIndex, dayDate in - guard let date = dayDate, dateRange.contains(date) else { return nil } - return createHeatmapDay( - for: date, - dayIndex: dayIndex, - weekIndex: weekIndex, - costLookup: costLookup, - maxCost: maxCost - ) - } - } - - func createHeatmapDay( - for date: Date, - dayIndex: Int, - weekIndex: Int, - costLookup: [String: Double], - maxCost: Double - ) -> HeatmapDay { - let dateString = dateCalculator.formatDateAsID(date) - let calendarProps = dateCalculator.calendarProperties(for: date) - - return HeatmapDay( - date: date, - cost: costLookup[dateString] ?? DataGenerationConstants.defaultCost, - dayOfYear: calendarProps.dayOfYear, - weekOfYear: weekIndex, - dayOfWeek: dayIndex, - maxCost: maxCost - ) - } -} - -// MARK: - Validation - -extension HeatmapStore { - - func validateDailyUsage(_ dailyUsage: [DailyUsage]) throws -> [DailyUsage] { - let invalidDates = findInvalidDates(in: dailyUsage) - - guard invalidDates.isEmpty else { - throw HeatmapError.invalidDateRange(formatInvalidDatesMessage(invalidDates)) - } - - return dailyUsage - } - - func calculateValidDateRange() throws -> (start: Date, end: Date) { - let dateRange = dateCalculator.rollingDateRangeWithCompleteWeeks( - numberOfDays: DataGenerationConstants.rollingDateRangeDays - ) - let validationErrors = dateCalculator.validateDateRange( - startDate: dateRange.start, - endDate: dateRange.end - ) - - guard validationErrors.isEmpty else { - throw HeatmapError.invalidDateRange(validationErrors.joined(separator: ", ")) - } - - return dateRange - } -} - -// MARK: - Pure Functions - -extension HeatmapStore { - - func buildCostLookup(from dailyUsage: [DailyUsage]) -> [String: Double] { - Dictionary( - dailyUsage.map { ($0.date, $0.totalCost) }, - uniquingKeysWith: { first, _ in first } - ) - } - - func calculateMaxCost(from dailyUsage: [DailyUsage]) -> Double { - dailyUsage - .map(\.totalCost) - .max() ?? DataGenerationConstants.defaultMaxCost - } -} - -// MARK: - Private Helpers - -private extension HeatmapStore { - - func delayForTestability() async throws { - try await Task.sleep(nanoseconds: DataGenerationConstants.testabilityDelayNanoseconds) - } - - func processStatsIntoDataset(_ stats: UsageStats) async throws -> HeatmapDataset { - let validDailyUsage = try validateDailyUsage(stats.byDate) - let dateRange = try calculateValidDateRange() - return await buildDataset(from: validDailyUsage, dateRange: dateRange) - } - - func findInvalidDates(in dailyUsage: [DailyUsage]) -> [String] { - let dateFormatter = makeDateFormatter() - return DailyUsageValidator.findInvalidDates(in: dailyUsage, using: dateFormatter) - } - - func makeDateFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = DataGenerationConstants.dateFormat - formatter.timeZone = TimeZone.current - return formatter - } - - func formatInvalidDatesMessage(_ dates: [String]) -> String { - "Invalid date format(s): \(dates.joined(separator: ", "))" - } -} - -// MARK: - Model Factories - -private extension HeatmapStore { - - func makeHeatmapMonth(from info: HeatmapDateCalculator.MonthInfo) -> HeatmapMonth { - HeatmapMonth( - name: info.name, - weekSpan: info.weekSpan, - fullName: info.fullName, - monthNumber: info.monthNumber, - year: info.year - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift deleted file mode 100644 index 4bee3cd..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// HeatmapStore+SupportingTypes.swift -// -// Supporting types for HeatmapStore: errors, stats, and factory. -// - -import Foundation - -// MARK: - Heatmap Error - -/// Heatmap-specific error types -public enum HeatmapError: Error, LocalizedError { - case invalidDateRange(String) - case dataProcessingFailed(String) - case configurationInvalid(String) - case performanceThresholdExceeded - - public var errorDescription: String? { - switch self { - case .invalidDateRange(let message): - return "Invalid date range: \(message)" - case .dataProcessingFailed(let message): - return "Data processing failed: \(message)" - case .configurationInvalid(let message): - return "Configuration invalid: \(message)" - case .performanceThresholdExceeded: - return "Performance threshold exceeded - consider using a smaller date range" - } - } -} - -// MARK: - Summary Statistics - -/// Summary statistics for heatmap data -public struct SummaryStats { - public let totalCost: Double - public let daysWithUsage: Int - public let totalDays: Int - public let maxDailyCost: Double - public let averageDailyCost: Double - public let dateRange: ClosedRange - - /// Usage frequency as percentage - public var usageFrequency: Double { - guard totalDays > 0 else { return 0 } - return Double(daysWithUsage) / Double(totalDays) * 100 - } - - /// Formatted date range string - public var dateRangeString: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - return "\(formatter.string(from: dateRange.lowerBound)) - \(formatter.string(from: dateRange.upperBound))" - } -} - -// MARK: - Performance Metrics - -/// Performance metrics for monitoring -struct PerformanceMetrics { - var datasetGenerationTime: Double = 0 - var hoverEventCount: Int = 0 - var averageHoverTime: Double = 0 - - var summary: String { - return """ - Dataset Generation: \(String(format: "%.3f", datasetGenerationTime))s - Hover Events: \(hoverEventCount) - Avg Hover Time: \(String(format: "%.4f", averageHoverTime))s - """ - } -} - -// MARK: - View Model Factory - -/// Factory for creating pre-configured view models -public struct HeatmapStoreFactory { - - /// Create view model optimized for performance - @MainActor - public static func performanceOptimized() -> HeatmapStore { - return HeatmapStore(configuration: .performanceOptimized) - } - - /// Create view model for compact displays - @MainActor - public static func compact() -> HeatmapStore { - return HeatmapStore(configuration: .compact) - } - - /// Create view model with custom configuration - /// - Parameter configuration: Custom configuration - /// - Returns: Configured view model - @MainActor - public static func custom(_ configuration: HeatmapConfiguration) -> HeatmapStore { - return HeatmapStore(configuration: configuration) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift deleted file mode 100644 index 23986e5..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift +++ /dev/null @@ -1,328 +0,0 @@ -// -// HeatmapStore.swift -// Business logic and state management for heatmap visualization -// -// Split into extensions for focused responsibilities: -// - +DataGeneration: Dataset generation and processing -// - +SupportingTypes: Error types, summary stats, factory -// - -import SwiftUI -import Foundation -import Observation -import ClaudeUsageCore -import OSLog - -private let logger = Logger(subsystem: "com.claudecodeusage", category: "HeatmapStore") - -// MARK: - Constants - -private enum ViewModelConstants { - static let gridContentPadding: CGFloat = 4 - static let daysPerWeek = 7 -} - -private enum AccessibilityStrings { - static let datePrefix = "Usage on" - static let costPrefix = "Cost:" -} - -// MARK: - Heatmap View Model - -/// View model managing heatmap data, state, and business logic -@Observable -@MainActor -public final class HeatmapStore { - - // MARK: - Observable Properties - - /// Current heatmap dataset - public internal(set) var dataset: HeatmapDataset? - - /// Currently hovered day - public var hoveredDay: HeatmapDay? - - /// Tooltip position for hovered day - public var tooltipPosition: CGPoint = .zero - - /// Whether tooltip should flip to left side (near right edge) - public var tooltipShouldFlipLeft: Bool = false - - /// Loading state - public private(set) var isLoading: Bool = false - - /// Error state - public private(set) var error: HeatmapError? - - /// Configuration settings - public var configuration: HeatmapConfiguration { - didSet { - // Regenerate dataset when configuration changes - if let stats = currentStats, configuration != oldValue { - Task { - await generateDataset(from: stats) - } - } - } - } - - // MARK: - Internal Properties - - /// Current usage stats - var currentStats: UsageStats? - - /// Date calculator utility - let dateCalculator = HeatmapDateCalculator.shared - - /// Color manager for optimized color calculations - let colorManager = HeatmapColorManager.shared - - /// Performance metrics - var performanceMetrics = PerformanceMetrics() - - // MARK: - Initialization - - /// Initialize with configuration - /// - Parameter configuration: Heatmap configuration (defaults to standard) - public init(configuration: HeatmapConfiguration = .default) { - self.configuration = configuration - } - - // MARK: - Public Interface (High Level) - - /// Update heatmap with new usage statistics - /// - Parameter stats: Usage statistics to visualize - public func updateStats(_ stats: UsageStats) async { - isLoading = true - error = nil - currentStats = stats - - await generateDataset(from: stats) - - isLoading = false - } - - /// Handle hover at specific location - /// - Parameters: - /// - location: Cursor location in heatmap coordinate space - /// - gridBounds: Bounds of the heatmap grid - public func handleHover(at location: CGPoint, in gridBounds: CGRect) { - trackHoverPerformance { - updateHoveredDay(at: location, in: gridBounds) - } - } - - /// End hover interaction - public func endHover() { - hoveredDay = nil - } - - /// Get accessibility label for a specific day - public func accessibilityLabel(for day: HeatmapDay) -> String { - formatAccessibilityLabel(dateString: day.dateString, costString: day.costString) - } - - /// Get summary statistics for the current dataset - public var summaryStats: SummaryStats? { - dataset.map(buildSummaryStats) - } - - // MARK: - Hover Handling - - /// Update hovered day based on location - func updateHoveredDay(at location: CGPoint, in gridBounds: CGRect) { - guard let dataset else { - clearHoveredDay() - return - } - - let day = findDayAtLocation(location, in: dataset) - guard isNewHoveredDay(day) else { return } - - hoveredDay = day - updateTooltipIfNeeded(for: day, in: dataset) - } - - /// Find day at specific location in heatmap grid - func findDayAtLocation(_ location: CGPoint, in dataset: HeatmapDataset) -> HeatmapDay? { - let indices = calculateGridIndices(from: location) - guard isValidGridPosition(indices, in: dataset) else { return nil } - return dataset.weeks[indices.week].days[indices.day] - } - - // MARK: - Performance Tracking - - /// Track hover event performance - func trackHoverPerformance(_ operation: () -> Void) { - performanceMetrics.hoverEventCount += 1 - let startTime = CFAbsoluteTimeGetCurrent() - - operation() - - let duration = CFAbsoluteTimeGetCurrent() - startTime - performanceMetrics.averageHoverTime = (performanceMetrics.averageHoverTime + duration) / 2 - } - - /// Record dataset generation time - func recordDatasetGenerationTime(since startTime: CFAbsoluteTime) { - let duration = CFAbsoluteTimeGetCurrent() - startTime - performanceMetrics.datasetGenerationTime = duration - logger.debug("Generated heatmap dataset in \(String(format: "%.3f", duration))s") - } - - /// Handle dataset generation error - func handleDatasetError(_ error: Error) { - self.error = error as? HeatmapError ?? .dataProcessingFailed(error.localizedDescription) - self.dataset = nil - } -} - -// MARK: - Public State Properties - -public extension HeatmapStore { - - /// Whether the heatmap has data to display - var hasData: Bool { - dataset?.weeks.isEmpty == false - } - - /// Whether the heatmap is in an error state - var hasError: Bool { - error != nil - } - - /// Whether the heatmap is currently interactive (not loading, has data) - var isInteractive: Bool { - !isLoading && hasData && !hasError - } -} - -// MARK: - Hover State Helpers - -private extension HeatmapStore { - - func clearHoveredDay() { - hoveredDay = nil - } - - func isNewHoveredDay(_ day: HeatmapDay?) -> Bool { - hoveredDay?.id != day?.id - } - - func updateTooltipIfNeeded(for day: HeatmapDay?, in dataset: HeatmapDataset) { - guard let day else { return } - tooltipPosition = calculateTooltipPosition(for: day) - tooltipShouldFlipLeft = shouldFlipTooltipLeft(for: day, totalWeeks: dataset.weeks.count) - } - - func calculateTooltipPosition(for day: HeatmapDay) -> CGPoint { - TooltipPositionCalculator.position( - for: day, - cellSize: configuration.cellSize, - squareSize: configuration.squareSize - ) - } - - func shouldFlipTooltipLeft(for day: HeatmapDay, totalWeeks: Int) -> Bool { - TooltipPositionCalculator.shouldFlipLeft(day: day, totalWeeks: totalWeeks) - } -} - -// MARK: - Grid Index Calculations - -private extension HeatmapStore { - - typealias GridIndices = (week: Int, day: Int) - - func calculateGridIndices(from location: CGPoint) -> GridIndices { - let cellSize = configuration.cellSize - let weekIndex = Int((location.x - ViewModelConstants.gridContentPadding) / cellSize) - let dayIndex = Int(location.y / cellSize) - return (week: weekIndex, day: dayIndex) - } - - func isValidGridPosition(_ indices: GridIndices, in dataset: HeatmapDataset) -> Bool { - isValidWeekIndex(indices.week, in: dataset) && isValidDayIndex(indices.day) - } - - func isValidWeekIndex(_ index: Int, in dataset: HeatmapDataset) -> Bool { - index >= 0 && index < dataset.weeks.count - } - - func isValidDayIndex(_ index: Int) -> Bool { - index >= 0 && index < ViewModelConstants.daysPerWeek - } -} - -// MARK: - Pure Transformation Functions - -private extension HeatmapStore { - - func buildSummaryStats(from dataset: HeatmapDataset) -> SummaryStats { - SummaryStats( - totalCost: dataset.totalCost, - daysWithUsage: dataset.daysWithUsage, - totalDays: dataset.allDays.count, - maxDailyCost: dataset.maxCost, - averageDailyCost: calculateAverageDailyCost(total: dataset.totalCost, days: dataset.allDays.count), - dateRange: dataset.dateRange - ) - } - - func calculateAverageDailyCost(total: Double, days: Int) -> Double { - total / Double(max(1, days)) - } - - func formatAccessibilityLabel(dateString: String, costString: String) -> String { - "\(AccessibilityStrings.datePrefix) \(dateString), \(AccessibilityStrings.costPrefix) \(costString)" - } -} - -// MARK: - Supporting Types - -/// Tooltip position calculation (pure functions) -enum TooltipPositionCalculator { - /// Fixed Y position for tooltip (in header area) - private static let fixedY: CGFloat = 30 - - /// Calculate tooltip position for a hovered day - /// - Parameters: - /// - day: The day being hovered - /// - cellSize: Size of each cell in the grid - /// - squareSize: Size of the day square (unused, kept for API compatibility) - /// - gridContentPadding: Horizontal padding applied to grid content - /// - Returns: Position for the tooltip (X follows column, Y fixed at header) - static func position( - for day: HeatmapDay, - cellSize: CGFloat, - squareSize: CGFloat, - gridContentPadding: CGFloat = 4 - ) -> CGPoint { - let squareCenterX = CGFloat(day.weekOfYear) * cellSize + (cellSize / 2) + gridContentPadding - - return CGPoint( - x: squareCenterX, - y: fixedY - ) - } - - /// Determine if tooltip should appear on the left side of the cell - static func shouldFlipLeft(day: HeatmapDay, totalWeeks: Int) -> Bool { - let weeksFromEnd = totalWeeks - day.weekOfYear - return weeksFromEnd < 8 - } -} - -/// Date validation (pure functions) -enum DailyUsageValidator { - /// Validate daily usage dates and return invalid date strings - /// - Parameters: - /// - dailyUsage: Array of daily usage records - /// - dateFormatter: Formatter to validate dates - /// - Returns: Array of invalid date strings - static func findInvalidDates(in dailyUsage: [DailyUsage], using dateFormatter: DateFormatter) -> [String] { - dailyUsage - .map(\.date) - .filter { dateFormatter.date(from: $0) == nil } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift deleted file mode 100644 index c1500fe..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// HeatmapTooltip+Factory.swift -// Convenience factory methods for common tooltip configurations -// - -import SwiftUI - -// MARK: - Convenience Extensions - -public extension HeatmapTooltip { - - /// Create a quick tooltip with minimal styling - /// - Parameters: - /// - day: Day data - /// - position: Position on screen - /// - Returns: Minimal tooltip - static func quick(day: HeatmapDay, position: CGPoint) -> HeatmapTooltip { - HeatmapTooltip( - day: day, - position: position, - style: .minimal, - configuration: .minimal - ) - } - - /// Create a rich tooltip with detailed information - /// - Parameters: - /// - day: Day data - /// - position: Position on screen - /// - Returns: Detailed tooltip - static func rich(day: HeatmapDay, position: CGPoint) -> HeatmapTooltip { - HeatmapTooltip( - day: day, - position: position, - style: .detailed, - configuration: .enhanced - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift deleted file mode 100644 index 25c8b15..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// HeatmapTooltip.swift -// Tooltip component for heatmap hover interactions -// - -import SwiftUI - -// MARK: - Heatmap Tooltip - -/// Customizable tooltip for displaying heatmap day information -public struct HeatmapTooltip: View { - - // MARK: - Configuration - - /// Tooltip content style - public enum TooltipStyle { - case minimal // Cost and date only - case standard // Cost, date, and basic info - case detailed // Cost, date, and additional statistics - case custom // Fully customizable content - } - - /// Tooltip positioning strategy - public enum PositioningStrategy { - case automatic // Smart positioning based on screen bounds - case fixed // Fixed offset from cursor - case adaptive // Adapts to content size - } - - // MARK: - Properties - - /// Day data to display - let day: HeatmapDay - - /// Tooltip position on screen - let position: CGPoint - - /// Display style - let style: TooltipStyle - - /// Positioning strategy - let positioning: PositioningStrategy - - /// Custom content builder (for .custom style) - let customContent: ((HeatmapDay) -> AnyView)? - - /// Screen bounds for smart positioning - let screenBounds: CGRect - - /// Whether to flip tooltip to the left side - let shouldFlipLeft: Bool - - /// Configuration settings - private let configuration: TooltipConfiguration - - // MARK: - Initialization - - /// Initialize tooltip with day data - /// - Parameters: - /// - day: Day to display information for - /// - position: Position on screen - /// - style: Display style (default: standard) - /// - positioning: Positioning strategy (default: automatic) - /// - screenBounds: Screen bounds for positioning - /// - shouldFlipLeft: Whether to flip tooltip to the left side - /// - configuration: Tooltip configuration - /// - customContent: Custom content builder (for .custom style) - public init( - day: HeatmapDay, - position: CGPoint, - style: TooltipStyle = .standard, - positioning: PositioningStrategy = .automatic, - screenBounds: CGRect = NSScreen.main?.frame ?? .zero, - shouldFlipLeft: Bool = false, - configuration: TooltipConfiguration = .default, - customContent: ((HeatmapDay) -> AnyView)? = nil - ) { - self.day = day - self.position = position - self.style = style - self.positioning = positioning - self.screenBounds = screenBounds - self.shouldFlipLeft = shouldFlipLeft - self.configuration = configuration - self.customContent = customContent - } - - // MARK: - Body - - public var body: some View { - tooltipContent - .background(tooltipBackground) - .cornerRadius(configuration.cornerRadius) - .shadow( - color: configuration.shadowColor, - radius: configuration.shadowRadius, - x: configuration.shadowOffset.width, - y: configuration.shadowOffset.height - ) - .offset(calculatedOffset) - .opacity(configuration.opacity) - .scaleEffect(configuration.scale) - .animation(configuration.animation, value: day.id) - } - - // MARK: - Tooltip Content (Mid Level) - - @ViewBuilder - private var tooltipContent: some View { - switch style { - case .minimal: - minimalContent - case .standard: - standardContent - case .detailed: - detailedContent - case .custom: - customContentView - } - } - - // MARK: - Content Variants (Mid Level) - - @ViewBuilder - private var minimalContent: some View { - VStack(alignment: .leading, spacing: 2) { - Text(day.costString) - .font(.system(size: 11, weight: .semibold)) - .foregroundColor(.primary) - - Text(day.dateString) - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - - @ViewBuilder - private var standardContent: some View { - VStack(alignment: .leading, spacing: 4) { - standardPrimaryRow - standardDateLabel - standardUsageStatus - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - } - - @ViewBuilder - private var detailedContent: some View { - VStack(alignment: .leading, spacing: 6) { - detailedHeader - detailedDivider - detailedStatistics - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - - @ViewBuilder - private var customContentView: some View { - if let customContent = customContent { - customContent(day) - } else { - standardContent - } - } - - // MARK: - Standard Content Components (Low Level) - - @ViewBuilder - private var standardPrimaryRow: some View { - HStack(spacing: 8) { - Text(day.costString) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.primary) - - if day.isToday { - standardTodayBadge - } - } - } - - private var standardTodayBadge: some View { - Text("Today") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.blue) - .cornerRadius(4) - } - - private var standardDateLabel: some View { - Text(day.dateString) - .font(.system(size: 10)) - .foregroundColor(.secondary) - } - - @ViewBuilder - private var standardUsageStatus: some View { - if day.isEmpty { - Text("No usage") - .font(.system(size: 9)) - .foregroundColor(.secondary) - } else { - Text("Usage recorded") - .font(.system(size: 9)) - .foregroundColor(.green) - } - } - - // MARK: - Detailed Content Components (Low Level) - - @ViewBuilder - private var detailedHeader: some View { - HStack { - detailedCostAndDate - Spacer() - if day.isToday { - detailedTodayBadge - } - } - } - - @ViewBuilder - private var detailedCostAndDate: some View { - VStack(alignment: .leading, spacing: 2) { - Text(day.costString) - .font(.system(size: 13, weight: .bold)) - .foregroundColor(.primary) - - Text(day.dateString) - .font(.system(size: 11)) - .foregroundColor(.secondary) - } - } - - @ViewBuilder - private var detailedTodayBadge: some View { - VStack(spacing: 2) { - Image(systemName: "calendar.badge.clock") - .font(.system(size: 12)) - .foregroundColor(.blue) - Text("Today") - .font(.system(size: 8, weight: .medium)) - .foregroundColor(.blue) - } - } - - private var detailedDivider: some View { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 0.5) - } - - @ViewBuilder - private var detailedStatistics: some View { - VStack(alignment: .leading, spacing: 3) { - if !day.isEmpty { - activityLevelRow - intensityRow - } else { - noActivityRow - } - dayOfYearLabel - } - } - - @ViewBuilder - private var activityLevelRow: some View { - HStack { - Text("Activity Level:") - .font(.system(size: 9)) - .foregroundColor(.secondary) - - Spacer() - - Text(activityLevel.text) - .font(.system(size: 9, weight: .medium)) - .foregroundColor(activityLevel.color) - } - } - - @ViewBuilder - private var intensityRow: some View { - HStack { - Text("Intensity:") - .font(.system(size: 9)) - .foregroundColor(.secondary) - - Spacer() - - Text("\(Int(day.intensity * 100))%") - .font(.system(size: 9, weight: .medium)) - .foregroundColor(.primary) - } - } - - @ViewBuilder - private var noActivityRow: some View { - HStack { - Image(systemName: "moon.zzz") - .font(.system(size: 10)) - .foregroundColor(.gray) - - Text("No activity recorded") - .font(.system(size: 9)) - .foregroundColor(.gray) - } - } - - private var dayOfYearLabel: some View { - Text("Day \(day.dayOfYear) of year") - .font(.system(size: 8)) - .foregroundColor(.secondary) - } - - // MARK: - Background - - @ViewBuilder - private var tooltipBackground: some View { - Rectangle() - .fill(configuration.backgroundMaterial) - } - - // MARK: - Computed Properties - - private var calculatedOffset: CGSize { - TooltipPositioning.offset( - for: positioning, - position: position, - screenBounds: screenBounds, - style: style, - shouldFlipLeft: shouldFlipLeft - ) - } - - private var activityLevel: ActivityLevel { - ActivityLevel(intensity: day.intensity) - } -} - -// MARK: - Preview - -#if DEBUG -struct HeatmapTooltip_Previews: PreviewProvider { - static var previews: some View { - let sampleDay = HeatmapDay( - date: Date(), - cost: 12.45, - dayOfYear: 285, - weekOfYear: 41, - dayOfWeek: 3, - maxCost: 25.0 - ) - - VStack(spacing: 30) { - // Minimal tooltip - HeatmapTooltip.quick(day: sampleDay, position: .zero) - - // Standard tooltip - HeatmapTooltip(day: sampleDay, position: .zero, style: .standard) - - // Detailed tooltip - HeatmapTooltip.rich(day: sampleDay, position: .zero) - } - .padding(50) - .background(Color(.windowBackgroundColor)) - } -} -#endif diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift deleted file mode 100644 index 96018d1..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// HeatmapTooltipBuilder.swift -// Builder pattern for creating customized tooltips -// - -import SwiftUI - -// MARK: - Tooltip Builder - -/// Builder for creating customized tooltips -public struct HeatmapTooltipBuilder: @unchecked Sendable { - private var day: HeatmapDay - private var position: CGPoint - private var style: HeatmapTooltip.TooltipStyle = .standard - private var positioning: HeatmapTooltip.PositioningStrategy = .automatic - private var screenBounds: CGRect = NSScreen.main?.frame ?? .zero - private var configuration: TooltipConfiguration = .default - private var customContent: ((HeatmapDay) -> AnyView)? - - public init(day: HeatmapDay, position: CGPoint) { - self.day = day - self.position = position - } - - public func style(_ tooltipStyle: HeatmapTooltip.TooltipStyle) -> Self { - var builder = self - builder.style = tooltipStyle - return builder - } - - public func positioning(_ strategy: HeatmapTooltip.PositioningStrategy) -> Self { - var builder = self - builder.positioning = strategy - return builder - } - - public func screenBounds(_ bounds: CGRect) -> Self { - var builder = self - builder.screenBounds = bounds - return builder - } - - public func configuration(_ config: TooltipConfiguration) -> Self { - var builder = self - builder.configuration = config - return builder - } - - public func customContent(_ content: @escaping (HeatmapDay) -> AnyView) -> Self { - var builder = self - builder.customContent = content - return builder - } - - public func build() -> HeatmapTooltip { - HeatmapTooltip( - day: day, - position: position, - style: style, - positioning: positioning, - screenBounds: screenBounds, - configuration: configuration, - customContent: customContent - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift deleted file mode 100644 index 32bd44e..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// TooltipConfiguration.swift -// Configuration struct for tooltip appearance and behavior -// - -import SwiftUI - -// MARK: - Tooltip Configuration - -/// Configuration for tooltip appearance and behavior -public struct TooltipConfiguration: @unchecked Sendable { - - /// Background material - public let backgroundMaterial: Material - - /// Corner radius - public let cornerRadius: CGFloat - - /// Shadow properties - public let shadowColor: Color - public let shadowRadius: CGFloat - public let shadowOffset: CGSize - - /// Opacity - public let opacity: Double - - /// Scale - public let scale: CGFloat - - /// Animation - public let animation: Animation? - - /// Default configuration - public static let `default` = TooltipConfiguration( - backgroundMaterial: .regularMaterial, - cornerRadius: 8, - shadowColor: .black.opacity(0.15), - shadowRadius: 6, - shadowOffset: CGSize(width: 0, height: 2), - opacity: 1.0, - scale: 1.0, - animation: .easeInOut(duration: 0.2) - ) - - /// Minimal configuration without shadows or animations - public static let minimal = TooltipConfiguration( - backgroundMaterial: .thinMaterial, - cornerRadius: 4, - shadowColor: .clear, - shadowRadius: 0, - shadowOffset: .zero, - opacity: 0.95, - scale: 1.0, - animation: nil - ) - - /// Enhanced configuration with prominent styling - public static let enhanced = TooltipConfiguration( - backgroundMaterial: .thickMaterial, - cornerRadius: 12, - shadowColor: .black.opacity(0.25), - shadowRadius: 10, - shadowOffset: CGSize(width: 0, height: 4), - opacity: 1.0, - scale: 1.05, - animation: .spring(response: 0.4, dampingFraction: 0.8) - ) -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift deleted file mode 100644 index 312963b..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// TooltipPositioning.swift -// Pure functions for tooltip positioning calculations -// - -import SwiftUI - -// MARK: - Tooltip Positioning (Pure Functions) - -enum TooltipPositioning { - - static func offset( - for strategy: HeatmapTooltip.PositioningStrategy, - position: CGPoint, - screenBounds: CGRect, - style: HeatmapTooltip.TooltipStyle, - shouldFlipLeft: Bool = false - ) -> CGSize { - switch strategy { - case .automatic: - smartOffset(position: position, screenBounds: screenBounds, style: style, shouldFlipLeft: shouldFlipLeft) - case .fixed: - CGSize(width: shouldFlipLeft ? -150 : 10, height: -30) - case .adaptive: - adaptiveOffset(style: style, shouldFlipLeft: shouldFlipLeft) - } - } - - private static func smartOffset( - position: CGPoint, - screenBounds: CGRect, - style: HeatmapTooltip.TooltipStyle, - shouldFlipLeft: Bool - ) -> CGSize { - let size = estimatedSize(for: style) - let gap: CGFloat = 8 - - // Position tooltip edge near cell center - // .position() places tooltip CENTER, so offset by half-width to align edge - let adjustedX: CGFloat = shouldFlipLeft - ? -(size.width / 2) - gap // Right edge near cell - : (size.width / 2) + gap // Left edge near cell - - return CGSize(width: adjustedX, height: 0) - } - - private static func adaptiveOffset(style: HeatmapTooltip.TooltipStyle, shouldFlipLeft: Bool) -> CGSize { - let size = estimatedSize(for: style) - let xOffset = shouldFlipLeft ? -size.width - 10 : -size.width / 2 - return CGSize(width: xOffset, height: -size.height - 15) - } - - static func estimatedSize(for style: HeatmapTooltip.TooltipStyle) -> CGSize { - switch style { - case .minimal: CGSize(width: 100, height: 35) - case .standard: CGSize(width: 140, height: 50) - case .detailed: CGSize(width: 180, height: 85) - case .custom: CGSize(width: 150, height: 60) - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift deleted file mode 100644 index b474f51..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// YearlyCostHeatmap+Factories.swift -// -// Static factory methods and legacy compatibility for YearlyCostHeatmap. -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Legacy Compatibility - -/// Legacy extension providing the original interface for backward compatibility -public extension YearlyCostHeatmap { - - /// Legacy initializer matching the original component interface - /// - Parameters: - /// - stats: Usage statistics - /// - year: Year (ignored, rolling year used instead) - /// - Returns: Configured heatmap with default settings - @available(*, deprecated, message: "Use init(stats:year:configuration:) with explicit configuration instead") - static func legacy(stats: UsageStats, year: Int) -> YearlyCostHeatmap { - return YearlyCostHeatmap( - stats: stats, - year: year, - configuration: .default - ) - } - - /// Performance-optimized version for large datasets - /// - Parameters: - /// - stats: Usage statistics - /// - year: Year (ignored) - /// - Returns: Performance-optimized heatmap - static func performanceOptimized(stats: UsageStats, year: Int) -> YearlyCostHeatmap { - return YearlyCostHeatmap( - stats: stats, - year: year, - configuration: .performanceOptimized - ) - } - - /// Compact version for limited space - /// - Parameters: - /// - stats: Usage statistics - /// - year: Year (ignored) - /// - Returns: Compact heatmap - static func compact(stats: UsageStats, year: Int) -> YearlyCostHeatmap { - return YearlyCostHeatmap( - stats: stats, - year: year, - configuration: .compact - ) - } -} - -// MARK: - Custom Configurations - -public extension YearlyCostHeatmap { - - /// Create heatmap with custom color theme - /// - Parameters: - /// - stats: Usage statistics - /// - year: Year (ignored) - /// - colorTheme: Custom color theme - /// - Returns: Heatmap with custom colors - static func withColorTheme( - stats: UsageStats, - year: Int, - colorTheme: HeatmapColorTheme - ) -> YearlyCostHeatmap { - let config = HeatmapConfiguration.default - // Note: This would require modifying HeatmapConfiguration to be mutable - // For now, we'll use the default configuration - return YearlyCostHeatmap(stats: stats, year: year, configuration: config) - } - - /// Create heatmap with accessibility optimizations - /// - Parameters: - /// - stats: Usage statistics - /// - year: Year (ignored) - /// - Returns: Accessibility-optimized heatmap - static func accessible(stats: UsageStats, year: Int) -> YearlyCostHeatmap { - // Create configuration optimized for accessibility - let config = HeatmapConfiguration( - squareSize: 14, // Larger squares - spacing: 3, // More spacing - cornerRadius: 2, - padding: EdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20), - colorScheme: .github, // High contrast theme would be better - showMonthLabels: true, - showDayLabels: true, - showLegend: true, - monthLabelFont: .body, // Larger font - dayLabelFont: .subheadline, - legendFont: .body, - enableTooltips: true, - tooltipDelay: 0.1, - highlightToday: true, - todayHighlightColor: .blue, - todayHighlightWidth: 3, // Thicker border - animationDuration: 0.0, // No animations for accessibility - animateColorTransitions: false, - scaleOnHover: false, - hoverScale: 1.0 - ) - - return YearlyCostHeatmap(stats: stats, year: year, configuration: config) - } -} - -// MARK: - Migration Guide - -/* - MIGRATION GUIDE: Upgrading from Legacy YearlyCostHeatmap - - The YearlyCostHeatmap component has been completely refactored with clean architecture. - While backward compatibility is maintained, consider migrating to the new API: - - OLD (still works): - ```swift - YearlyCostHeatmap(stats: stats, year: 2024) - ``` - - NEW (recommended): - ```swift - YearlyCostHeatmap( - stats: stats, - year: 2024, - configuration: .default // or .performanceOptimized, .compact - ) - ``` - - PERFORMANCE OPTIMIZED: - ```swift - YearlyCostHeatmap.performanceOptimized(stats: stats, year: 2024) - ``` - - COMPACT VERSION: - ```swift - YearlyCostHeatmap.compact(stats: stats, year: 2024) - ``` - - ACCESSIBILITY OPTIMIZED: - ```swift - YearlyCostHeatmap.accessible(stats: stats, year: 2024) - ``` - - BENEFITS OF MIGRATION: - - Better performance with optimized configurations - - Improved accessibility support - - More customization options - - Better error handling and loading states - - Type-safe configuration - - Easier testing with separated concerns - */ diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift deleted file mode 100644 index 9710d8e..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// YearlyCostHeatmap+Preview.swift -// -// Preview provider for YearlyCostHeatmap development. -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Preview - -#if DEBUG -struct YearlyCostHeatmap_Previews: PreviewProvider { - static var previews: some View { - ScrollView { - VStack(spacing: 30) { - // Default configuration - YearlyCostHeatmap(stats: sampleStats, year: 2024) - - // Performance optimized - YearlyCostHeatmap.performanceOptimized(stats: sampleStats, year: 2024) - - // Compact version - YearlyCostHeatmap.compact(stats: sampleStats, year: 2024) - - // Accessibility optimized - YearlyCostHeatmap.accessible(stats: sampleStats, year: 2024) - } - .padding() - } - .background(Color(.windowBackgroundColor)) - } - - static var sampleStats: UsageStats { - let dailyUsage = generateSampleDailyUsage() - return UsageStats( - totalCost: dailyUsage.reduce(0) { $0 + $1.totalCost }, - tokens: TokenCounts( - input: 250000, - output: 150000, - cacheCreation: 0, - cacheRead: 0 - ), - sessionCount: 150, - byModel: [], - byDate: dailyUsage, - byProject: [] - ) - } - - private static func generateSampleDailyUsage() -> [DailyUsage] { - let calendar = Calendar.current - let today = Date() - let dateFormatter = makeDateFormatter() - - return (0..<365).map { dayOffset in - makeDailyUsage( - dayOffset: dayOffset, - today: today, - calendar: calendar, - dateFormatter: dateFormatter - ) - } - } - - private static func makeDateFormatter() -> DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - } - - private static func makeDailyUsage( - dayOffset: Int, - today: Date, - calendar: Calendar, - dateFormatter: DateFormatter - ) -> DailyUsage { - let date = calendar.date(byAdding: .day, value: -dayOffset, to: today)! - let cost = calculateSampleCost(for: date, dayOffset: dayOffset, calendar: calendar) - return DailyUsage( - date: dateFormatter.string(from: date), - totalCost: cost, - totalTokens: Int(cost * 1000), - modelsUsed: ["claude-sonnet-4"] - ) - } - - private static func calculateSampleCost( - for date: Date, - dayOffset: Int, - calendar: Calendar - ) -> Double { - let hasNoRecentActivity = dayOffset >= 300 - guard !hasNoRecentActivity else { return 0 } - - let weekday = calendar.component(.weekday, from: date) - let isWeekend = weekday == 1 || weekday == 7 - let baseUsage = isWeekend ? 0.3 : 1.0 - let randomFactor = Double.random(in: 0.2...1.8) - let rawCost = baseUsage * randomFactor * 3.0 - let simulateNoUsageDay = rawCost > 2.8 - return simulateNoUsageDay ? 0 : rawCost - } -} -#endif diff --git a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift b/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift deleted file mode 100644 index fb92e0a..0000000 --- a/ClaudeCodeUsage/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// YearlyCostHeatmap.swift -// Refactored yearly cost heatmap with clean architecture -// -// Modern SwiftUI heatmap component with MVVM architecture, -// comprehensive error handling, and performance optimizations. -// -// Split into extensions for focused responsibilities: -// - +Factories: Static factory methods and legacy compatibility -// - +Preview: Preview provider for development -// - -import SwiftUI -import ClaudeUsageCore -import Foundation - -// MARK: - Yearly Cost Heatmap - -/// GitHub-style contribution graph for daily cost visualization -/// -/// This component has been completely refactored to follow clean architecture principles: -/// - MVVM architecture with dedicated ViewModel -/// - Separated data models and business logic -/// - Reusable subcomponents (Grid, Legend, Tooltip) -/// - Comprehensive error handling and validation -/// - Performance optimizations with caching -/// - Accessibility support -/// - Backward compatibility maintained -public struct YearlyCostHeatmap: View { - - // MARK: - Properties - - /// Usage statistics to visualize - let stats: UsageStats - - /// Year parameter (kept for backward compatibility, now ignored in favor of rolling year) - let year: Int - - /// Configuration for heatmap appearance and behavior - let configuration: HeatmapConfiguration - - /// View model managing data and state - @State private var viewModel: HeatmapStore - - /// Screen bounds for tooltip positioning - @State private var screenBounds: CGRect = NSScreen.main?.frame ?? .zero - - // MARK: - Initialization - - /// Initialize with usage statistics and optional configuration - /// - Parameters: - /// - stats: Usage statistics to display - /// - year: Year (kept for backward compatibility, ignored) - /// - configuration: Heatmap configuration (defaults to standard) - public init( - stats: UsageStats, - year: Int, - configuration: HeatmapConfiguration = .default - ) { - self.stats = stats - self.year = year - self.configuration = configuration - - // Initialize view model with configuration - self._viewModel = State(wrappedValue: HeatmapStore(configuration: configuration)) - } - - // MARK: - Body - - public var body: some View { - VStack(alignment: .leading, spacing: 16) { - headerSection - contentSection - legendSectionIfVisible - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(configuration.padding) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(12) - .overlay(tooltipOverlay, alignment: .topLeading) - .task { await viewModel.updateStats(stats) } - .onAppear { screenBounds = NSScreen.main?.frame ?? .zero } - } - - private var shouldShowLegend: Bool { - configuration.showLegend && viewModel.hasData - } - - @ViewBuilder - private var legendSectionIfVisible: some View { - if shouldShowLegend { - legendSection - } - } - - // MARK: - Header Section - - @ViewBuilder - private var headerSection: some View { - HStack { - titleAndSummary - Spacer() - costSummary - } - } - - private var titleAndSummary: some View { - VStack(alignment: .leading, spacing: 4) { - headerTitle - summarySubtitle - } - } - - private var headerTitle: some View { - HStack { - Image(systemName: "calendar.badge.plus") - .foregroundColor(.green) - Text("Daily Cost Activity") - .font(.headline) - .fontWeight(.semibold) - } - } - - @ViewBuilder - private var summarySubtitle: some View { - if let summary = viewModel.summaryStats { - Text("\(summary.daysWithUsage) days of usage in last 365 days") - .font(.caption) - .foregroundColor(.secondary) - } else if viewModel.isLoading { - Text("Loading usage data...") - .font(.caption) - .foregroundColor(.secondary) - } - } - - @ViewBuilder - private var costSummary: some View { - VStack(alignment: .trailing, spacing: 4) { - if let summary = viewModel.summaryStats { - Text(summary.totalCost.asCurrency) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text("Last 365 days") - .font(.caption) - .foregroundColor(.secondary) - } else if viewModel.isLoading { - ProgressView() - .scaleEffect(0.8) - } - } - } - - // MARK: - Content Section - - @ViewBuilder - private var contentSection: some View { - if viewModel.isLoading { - loadingView - } else if let error = viewModel.error { - errorView(error) - } else if let dataset = viewModel.dataset { - heatmapContent(dataset) - } else { - emptyStateView - } - } - - // MARK: - Loading View - - @ViewBuilder - private var loadingView: some View { - VStack(spacing: 12) { - ProgressView() - .scaleEffect(1.2) - - Text("Generating heatmap...") - .font(.subheadline) - .foregroundColor(.secondary) - } - .frame(height: 120) - .frame(maxWidth: .infinity) - } - - // MARK: - Error View - - @ViewBuilder - private func errorView(_ error: HeatmapError) -> some View { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 24)) - .foregroundColor(.orange) - - Text("Unable to Display Heatmap") - .font(.headline) - .foregroundColor(.primary) - - Text(error.localizedDescription) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - - Button("Retry") { - Task { - await viewModel.updateStats(stats) - } - } - .buttonStyle(.borderedProminent) - } - .frame(height: 120) - .frame(maxWidth: .infinity) - .padding() - } - - // MARK: - Empty State View - - @ViewBuilder - private var emptyStateView: some View { - VStack(spacing: 12) { - Image(systemName: "calendar") - .font(.system(size: 24)) - .foregroundColor(.gray) - - Text("No Usage Data") - .font(.headline) - .foregroundColor(.primary) - - Text("Heatmap will appear once you have usage data.") - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - } - .frame(height: 120) - .frame(maxWidth: .infinity) - } - - // MARK: - Heatmap Content - - @ViewBuilder - private func heatmapContent(_ dataset: HeatmapDataset) -> some View { - let gridLayout = HeatmapGridLayout(configuration: configuration, dataset: dataset) - - HeatmapGrid( - dataset: dataset, - configuration: configuration, - hoveredDay: viewModel.hoveredDay, - onHover: { location in - viewModel.handleHover(at: location, in: .zero) - }, - onEndHover: { - viewModel.endHover() - } - ) - .frame(height: gridLayout.totalSize.height) - .accessibilityLabel("Heatmap showing daily cost activity over the last 365 days") - .accessibilityAddTraits(.allowsDirectInteraction) - } - - // MARK: - Legend Section - - @ViewBuilder - private var legendSection: some View { - if let dataset = viewModel.dataset { - HeatmapLegend( - colorTheme: configuration.colorScheme, - maxCost: dataset.maxCost, - style: .horizontal, - font: configuration.legendFont - ) - } - } - - // MARK: - Tooltip Overlay - - @ViewBuilder - private var tooltipOverlay: some View { - if let hoveredDay = viewModel.hoveredDay, - configuration.enableTooltips { - HeatmapTooltip( - day: hoveredDay, - position: viewModel.tooltipPosition, - style: .standard, - screenBounds: screenBounds, - shouldFlipLeft: viewModel.tooltipShouldFlipLeft - ) - .position(viewModel.tooltipPosition) - .allowsHitTesting(false) - .animation(.easeInOut(duration: 0.1), value: viewModel.tooltipPosition) - } - } -} diff --git a/ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift b/ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift deleted file mode 100644 index f4e0417..0000000 --- a/ClaudeCodeUsage/MainWindow/Components/EmptyStateView.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// EmptyStateView.swift -// Reusable empty state component -// - -import SwiftUI - -struct EmptyStateView: View { - let icon: String - let title: String - let message: String - - var body: some View { - VStack(spacing: 20) { - iconView - titleView - messageView - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 100) - } - - private var iconView: some View { - Image(systemName: icon) - .font(.system(size: 60)) - .foregroundColor(.secondary) - } - - private var titleView: some View { - Text(title) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - - private var messageView: some View { - Text(message) - .font(.body) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 400) - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift b/ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift deleted file mode 100644 index 9016a9c..0000000 --- a/ClaudeCodeUsage/MainWindow/Daily/DailyUsageView.swift +++ /dev/null @@ -1,303 +0,0 @@ -// -// DailyUsageView.swift -// Daily usage breakdown view -// - -import SwiftUI -import ClaudeUsageCore - -struct DailyUsageView: View { - @Environment(UsageStore.self) private var store - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - DailyUsageHeader() - DailyUsageContent(state: ContentState.from(store: store)) - } - .padding() - } - .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Content State - -@MainActor -private enum ContentState { - case loading - case empty - case loaded([DailyUsage]) - case error - - static func from(store: UsageStore) -> ContentState { - if store.isLoading { return .loading } - guard let stats = store.stats else { return .error } - return stats.byDate.isEmpty ? .empty : .loaded(stats.byDate) - } -} - -// MARK: - Content Router - -private struct DailyUsageContent: View { - let state: ContentState - - var body: some View { - switch state { - case .loading: - LoadingView(message: "Loading daily usage...") - case .empty: - EmptyStateView( - icon: "calendar", - title: "No Usage Data", - message: "Daily usage statistics will appear here once you start using Claude Code.\nData is collected from ~/.claude/projects/" - ) - case .loaded(let dates): - DailyUsageList(dates: dates) - case .error: - EmptyStateView( - icon: "calendar", - title: "No Data Available", - message: "Unable to load daily usage data." - ) - } - } -} - -// MARK: - Header - -private struct DailyUsageHeader: View { - var body: some View { - VStack(alignment: .leading, spacing: 8) { - titleView - subtitleView - } - } - - private var titleView: some View { - Text("Daily Usage") - .font(.largeTitle) - .fontWeight(.bold) - } - - private var subtitleView: some View { - Text("Day-by-day breakdown of your usage") - .font(.subheadline) - .foregroundColor(.secondary) - } -} - -// MARK: - Loading View - -private struct LoadingView: View { - let message: String - - var body: some View { - ProgressView(message) - .frame(maxWidth: .infinity) - .padding(.top, 50) - } -} - -// MARK: - Grid - -private struct DailyUsageList: View { - let dates: [DailyUsage] - - private enum Layout { - static let minColumnWidth: CGFloat = 300 - static let spacing: CGFloat = 12 - } - - private var globalMaxHourlyCost: Double { - dates.flatMap(\.hourlyCosts).max() ?? 1.0 - } - - private var gridItems: [GridItem] { - [GridItem(.adaptive(minimum: Layout.minColumnWidth), spacing: Layout.spacing)] - } - - var body: some View { - LazyVGrid(columns: gridItems, spacing: Layout.spacing) { - ForEach(dates.reversed()) { daily in - DailyCard(daily: daily, maxHourlyCost: globalMaxHourlyCost) - } - } - } -} - -// MARK: - Daily Card - -struct DailyCard: View { - let daily: DailyUsage - var maxHourlyCost: Double? = nil // Shared scale for comparing charts - - private var dateInfo: DateInfo { DateInfo.from(daily.date) } - - var body: some View { - VStack(spacing: 12) { - summaryRow - hourlyChart - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(12) - } - - private var summaryRow: some View { - HStack(spacing: 16) { - DateBadge(info: dateInfo) - DateDetails(info: dateInfo, modelCount: daily.modelsUsed.count) - Spacer() - CostMetrics(cost: daily.totalCost, tokens: daily.totalTokens) - } - } - - private var hourlyChart: some View { - HourlyCostChartSimple(hourlyData: daily.hourlyCosts, maxScale: maxHourlyCost) - } -} - -// MARK: - Card Components - -private struct DateBadge: View { - let info: DateInfo - - var body: some View { - VStack(spacing: 4) { - dayText - monthText - } - .frame(width: 50) - .padding(.vertical, 8) - .background(info.isToday ? Color.accentColor : Color.gray.opacity(0.2)) - .foregroundColor(info.isToday ? .white : .primary) - .cornerRadius(8) - } - - private var dayText: some View { - Text(info.dayOfMonth) - .font(.title2) - .fontWeight(.bold) - } - - private var monthText: some View { - Text(info.monthAbbreviation) - .font(.caption) - .textCase(.uppercase) - } -} - -private struct DateDetails: View { - let info: DateInfo - let modelCount: Int - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - titleRow - modelCountText - } - } - - private var titleRow: some View { - HStack { - Text(info.dayOfWeek) - .font(.headline) - .lineLimit(1) - todayBadge - } - } - - @ViewBuilder - private var todayBadge: some View { - Badge(text: "TODAY", color: .accentColor) - .opacity(info.isToday ? 1 : 0) - } - - private var modelCountText: some View { - Text("\(modelCount) model\(modelCount == 1 ? "" : "s") used") - .font(.caption) - .foregroundColor(.secondary) - } -} - -private struct CostMetrics: View { - let cost: Double - let tokens: Int - - var body: some View { - VStack(alignment: .trailing, spacing: 4) { - costText - tokenText - } - } - - private var costText: some View { - Text(cost.asCurrency) - .font(.system(.body, design: .monospaced)) - .fontWeight(.semibold) - } - - private var tokenText: some View { - Text("\(tokens.abbreviated) tokens") - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .fixedSize() - } -} - -private struct Badge: View { - let text: String - let color: Color - - var body: some View { - Text(text) - .font(.caption2) - .fontWeight(.semibold) - .lineLimit(1) - .fixedSize() - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(color.opacity(0.2)) - .foregroundColor(color) - .cornerRadius(4) - } -} - -// MARK: - Pure Transformations - -private struct DateInfo { - let dayOfMonth: String - let monthAbbreviation: String - let dayOfWeek: String - let isToday: Bool - - static func from(_ dateString: String) -> DateInfo { - let components = dateString.split(separator: "-") - let day = components.count == 3 ? String(components[2]) : "" - - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - - guard let date = formatter.date(from: dateString) else { - return DateInfo(dayOfMonth: day, monthAbbreviation: "", dayOfWeek: dateString, isToday: false) - } - - formatter.dateFormat = "MMM" - let month = formatter.string(from: date) - - formatter.dateFormat = "EEEE" - let weekday = formatter.string(from: date) - - formatter.dateFormat = "yyyy-MM-dd" - let todayString = formatter.string(from: Date()) - - return DateInfo( - dayOfMonth: day, - monthAbbreviation: month, - dayOfWeek: weekday, - isToday: dateString == todayString - ) - } -} diff --git a/ClaudeCodeUsage/MainWindow/MainView.swift b/ClaudeCodeUsage/MainWindow/MainView.swift deleted file mode 100644 index 260a1f8..0000000 --- a/ClaudeCodeUsage/MainWindow/MainView.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// MainView.swift -// Main window with sidebar navigation -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Notification Names - -public extension Notification.Name { - static let refreshData = Notification.Name("refreshData") -} - -// MARK: - Main View -public struct MainView: View { - @Environment(UsageStore.self) private var store - @State private var selectedDestination: Destination? = .overview - let settingsService: AppSettingsService - - public init(settingsService: AppSettingsService) { - self.settingsService = settingsService - } - - public var body: some View { - NavigationContent( - selectedDestination: $selectedDestination, - store: store, - settingsService: settingsService - ) - } -} - -// MARK: - Navigation Content -private struct NavigationContent: View { - @Binding var selectedDestination: Destination? - let store: UsageStore - let settingsService: AppSettingsService - - var body: some View { - NavigationSplitView { - Sidebar(selectedDestination: $selectedDestination) - } detail: { - DetailView( - destination: selectedDestination ?? .overview, - store: store, - settingsService: settingsService - ) - } - .onReceive(NotificationCenter.default.publisher(for: .refreshData)) { _ in - Task { - await store.loadData() - } - } - } -} - -// MARK: - Sidebar -private struct Sidebar: View { - @Binding var selectedDestination: Destination? - - var body: some View { - List(selection: $selectedDestination) { - NavigationLink(value: Destination.overview) { - Label("Overview", systemImage: "chart.line.uptrend.xyaxis") - } - - NavigationLink(value: Destination.models) { - Label("Models", systemImage: "cpu") - } - - NavigationLink(value: Destination.dailyUsage) { - Label("Daily Usage", systemImage: "calendar") - } - - NavigationLink(value: Destination.analytics) { - Label("Analytics", systemImage: "chart.bar.xaxis") - } - - NavigationLink(value: Destination.liveMetrics) { - Label("Live Metrics", systemImage: "arrow.triangle.2.circlepath") - } - } - .navigationTitle(AppMetadata.name) - .frame(minWidth: 200) - .listStyle(.sidebar) - } -} - -// MARK: - Detail View -private struct DetailView: View { - let destination: Destination - let store: UsageStore - let settingsService: AppSettingsService - - var body: some View { - content - .frame(minWidth: 700) - } - - @ViewBuilder - private var content: some View { - switch destination { - case .overview: - OverviewView() - .environment(store) - case .models: - ModelsView() - .environment(store) - case .dailyUsage: - DailyUsageView() - .environment(store) - case .analytics: - AnalyticsView() - .environment(store) - case .liveMetrics: - MenuBarContentView(settingsService: settingsService, viewMode: .liveMetrics) - .environment(store) - } - } -} - -// MARK: - Navigation Destination -enum Destination: Hashable { - case overview - case models - case dailyUsage - case analytics - case liveMetrics -} - -// MARK: - Preview - -#if DEBUG -struct MainViewPreview: View { - @State private var store = UsageStore() - - var body: some View { - MainView(settingsService: AppSettingsService()) - .environment(store) - .frame(width: 1000, height: 700) - .task { await store.loadData() } - } -} - -#Preview { - MainViewPreview() -} -#endif diff --git a/ClaudeCodeUsage/MainWindow/Models/ModelsView.swift b/ClaudeCodeUsage/MainWindow/Models/ModelsView.swift deleted file mode 100644 index 31b758f..0000000 --- a/ClaudeCodeUsage/MainWindow/Models/ModelsView.swift +++ /dev/null @@ -1,254 +0,0 @@ -// -// ModelsView.swift -// Model usage breakdown view -// - -import SwiftUI -import ClaudeUsageCore - -struct ModelsView: View { - @Environment(UsageStore.self) private var store - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - ModelsHeader() - ModelsContent(state: ContentState.from(store: store)) - } - .padding() - } - .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Content State - -@MainActor -private enum ContentState { - case loading - case empty - case loaded(models: [ModelUsage], totalCost: Double) - case error - - static func from(store: UsageStore) -> ContentState { - if store.isLoading { return .loading } - guard let stats = store.stats else { return .error } - let sortedModels = stats.byModel.sorted { $0.totalCost > $1.totalCost } - return sortedModels.isEmpty ? .empty : .loaded(models: sortedModels, totalCost: stats.totalCost) - } -} - -// MARK: - Content Router - -private struct ModelsContent: View { - let state: ContentState - - var body: some View { - switch state { - case .loading: - LoadingView(message: "Loading models...") - case .empty: - EmptyStateView( - icon: "cpu", - title: "No Model Data", - message: "Model usage will appear here once you start using Claude Code." - ) - case .loaded(let models, let totalCost): - ModelsList(models: models, totalCost: totalCost) - case .error: - EmptyStateView( - icon: "cpu", - title: "No Data Available", - message: "Unable to load model usage data." - ) - } - } -} - -// MARK: - Header - -private struct ModelsHeader: View { - var body: some View { - VStack(alignment: .leading, spacing: 8) { - titleView - subtitleView - } - } - - private var titleView: some View { - Text("Model Usage") - .font(.largeTitle) - .fontWeight(.bold) - } - - private var subtitleView: some View { - Text("Breakdown by AI model") - .font(.subheadline) - .foregroundColor(.secondary) - } -} - -// MARK: - Loading View - -private struct LoadingView: View { - let message: String - - var body: some View { - ProgressView(message) - .frame(maxWidth: .infinity) - .padding(.top, 50) - } -} - -// MARK: - List - -private struct ModelsList: View { - let models: [ModelUsage] - let totalCost: Double - - var body: some View { - VStack(spacing: 12) { - ForEach(models) { model in - ModelCard(model: model, totalCost: totalCost) - } - } - } -} - -// MARK: - Model Card - -struct ModelCard: View { - let model: ModelUsage - let totalCost: Double - - private var metrics: ModelMetrics { ModelMetrics.from(model: model, totalCost: totalCost) } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - ModelHeader(name: model.model, cost: model.totalCost) - UsageBar(percentage: metrics.percentage, color: metrics.color) - ModelStats(model: model, percentage: metrics.percentage) - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(12) - } -} - -// MARK: - Card Components - -private struct ModelHeader: View { - let name: String - let cost: Double - - var body: some View { - HStack { - Label(ModelNameFormatter.format(name), systemImage: "cpu") - .font(.headline) - Spacer() - Text(cost.asCurrency) - .font(.system(.body, design: .monospaced)) - .fontWeight(.semibold) - } - } -} - -private struct UsageBar: View { - let percentage: Double - let color: Color - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.gray.opacity(0.2)) - .frame(height: 8) - RoundedRectangle(cornerRadius: 4) - .fill(color) - .frame(width: geometry.size.width * (percentage / 100), height: 8) - } - } - .frame(height: 8) - } -} - -private struct ModelStats: View { - let model: ModelUsage - let percentage: Double - - var body: some View { - HStack { - StatLabel(icon: "doc.text", value: "\(model.sessionCount) sessions") - Spacer() - StatLabel(icon: "number", value: "\(model.tokens.total.abbreviated) tokens") - Spacer() - StatLabel(icon: "percent", value: percentage.asPercentage) - } - } -} - -private struct StatLabel: View { - let icon: String - let value: String - - var body: some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.caption) - .foregroundColor(.secondary) - Text(value) - .font(.caption) - .foregroundColor(.secondary) - } - } -} - -// MARK: - Pure Transformations - -private struct ModelMetrics { - let percentage: Double - let color: Color - - static func from(model: ModelUsage, totalCost: Double) -> ModelMetrics { - let pct = totalCost > 0 ? (model.totalCost / totalCost) * 100 : 0 - let color = ModelColorResolver.color(for: model.model) - return ModelMetrics(percentage: pct, color: color) - } -} - -private enum ModelColorResolver { - static func color(for modelName: String) -> Color { - if modelName.contains("opus") { return .purple } - if modelName.contains("sonnet") { return .blue } - if modelName.contains("haiku") { return .green } - return .gray - } -} - -private enum ModelNameFormatter { - private static let knownFamilies = ["opus", "sonnet", "haiku"] - - static func format(_ model: String) -> String { - let parts = model.lowercased().components(separatedBy: "-") - let family = extractFamily(from: parts) - let version = extractVersion(from: parts) - return buildDisplayName(family: family, version: version, fallback: model) - } - - private static func extractFamily(from parts: [String]) -> String? { - parts.first { knownFamilies.contains($0) } - } - - private static func extractVersion(from parts: [String]) -> String { - let numbers = parts.compactMap { Int($0) } - return numbers.count >= 2 - ? "\(numbers[0]).\(numbers[1])" - : numbers.first.map { "\($0)" } ?? "" - } - - private static func buildDisplayName(family: String?, version: String, fallback: String) -> String { - guard let family = family else { return fallback } - let capitalizedFamily = family.capitalized - return version.isEmpty ? "Claude \(capitalizedFamily)" : "Claude \(capitalizedFamily) \(version)" - } -} diff --git a/ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift b/ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift deleted file mode 100644 index a7e4ec1..0000000 --- a/ClaudeCodeUsage/MainWindow/Overview/MetricCard.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// MetricCard.swift -// Reusable metric card component -// - -import SwiftUI - -struct MetricCard: View { - let title: String - let value: String - let icon: String - let color: Color - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Label(title, systemImage: icon) - .font(.caption) - .foregroundColor(.secondary) - - Text(value) - .font(.title2) - .fontWeight(.bold) - .foregroundColor(color) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - .background(color.opacity(0.1)) - .cornerRadius(12) - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift b/ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift deleted file mode 100644 index f52a9f4..0000000 --- a/ClaudeCodeUsage/MainWindow/Overview/OverviewView.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// OverviewView.swift -// Overview dashboard view -// - -import SwiftUI -import ClaudeUsageCore - -struct OverviewView: View { - @Environment(UsageStore.self) private var store - - var body: some View { - ScrollView { - OverviewContent(state: ContentState.from(store: store)) - } - .frame(minWidth: 600, idealWidth: 840, maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Content State - -@MainActor -private enum ContentState { - case loading - case loaded(UsageStats) - case error - - static func from(store: UsageStore) -> ContentState { - if store.isLoading { return .loading } - guard let stats = store.stats else { return .error } - return .loaded(stats) - } -} - -// MARK: - Content Router - -private struct OverviewContent: View { - let state: ContentState - - var body: some View { - switch state { - case .loading: - LoadingView(message: "Loading...") - case .loaded(let stats): - LoadedContent(stats: stats) - case .error: - EmptyStateView( - icon: "chart.line.uptrend.xyaxis", - title: "No Data Available", - message: "Run Claude Code sessions to generate usage data." - ) - } - } -} - -private struct LoadedContent: View { - let stats: UsageStats - - var body: some View { - VStack(alignment: .leading, spacing: 20) { - OverviewHeader() - MetricsGrid(stats: stats) - CostBreakdownSection(stats: stats) - } - .padding() - } -} - -// MARK: - Header - -private struct OverviewHeader: View { - var body: some View { - VStack(alignment: .leading, spacing: 8) { - titleView - subtitleView - } - } - - private var titleView: some View { - Text("Overview") - .font(.largeTitle) - .fontWeight(.bold) - } - - private var subtitleView: some View { - Text("Your Claude Code usage at a glance") - .font(.subheadline) - .foregroundColor(.secondary) - } -} - -// MARK: - Loading View - -private struct LoadingView: View { - let message: String - - var body: some View { - ProgressView(message) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.top, 100) - } -} - -// MARK: - Metrics Grid - -private struct MetricsGrid: View { - let stats: UsageStats - - private let columns = [ - GridItem(.flexible()), - GridItem(.flexible()) - ] - - var body: some View { - LazyVGrid(columns: columns, spacing: 16) { - totalCostCard - totalSessionsCard - totalTokensCard - avgCostCard - } - } - - private var totalCostCard: some View { - MetricCard(title: "Total Cost", value: stats.totalCost.asCurrency, icon: "dollarsign.circle", color: .green) - } - - private var totalSessionsCard: some View { - MetricCard(title: "Total Sessions", value: "\(stats.sessionCount)", icon: "doc.text", color: .blue) - } - - private var totalTokensCard: some View { - MetricCard(title: "Total Tokens", value: stats.totalTokens.abbreviated, icon: "number", color: .purple) - } - - private var avgCostCard: some View { - MetricCard(title: "Avg Cost/Session", value: stats.averageCostPerSession.asCurrency, icon: "chart.line.uptrend.xyaxis", color: .orange) - } -} - -// MARK: - Cost Breakdown Section - -private struct CostBreakdownSection: View { - let stats: UsageStats - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - sectionTitle - breakdownList - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(12) - } - - private var sectionTitle: some View { - Text("Cost Breakdown by Model") - .font(.headline) - } - - private var breakdownList: some View { - VStack(spacing: 8) { - ForEach(UsageAnalytics.costBreakdown(from: stats), id: \.model) { item in - CostBreakdownRow(item: item) - } - } - } -} - -// MARK: - Cost Breakdown Row - -private struct CostBreakdownRow: View { - let item: (model: String, cost: Double, percentage: Double) - - var body: some View { - HStack { - modelNameText - Spacer() - percentageText - costText - } - .padding(.vertical, 4) - } - - private var modelNameText: some View { - Text(ModelNameFormatter.format(item.model)) - .font(.subheadline) - } - - private var percentageText: some View { - Text(item.percentage.asPercentage) - .font(.caption) - .foregroundColor(.secondary) - } - - private var costText: some View { - Text(item.cost.asCurrency) - .font(.system(.body, design: .monospaced)) - .frame(minWidth: 80, alignment: .trailing) - } -} - -// MARK: - Pure Transformations - -private enum ModelNameFormatter { - /// Formats model ID to display name: "claude-opus-4-5-20251101" → "Claude Opus 4.5" - static func format(_ model: String) -> String { - let parts = model.lowercased().components(separatedBy: "-") - let family = extractFamily(from: parts) - let version = extractVersion(from: parts) - return buildDisplayName(family: family, version: version, fallback: model) - } - - private static func extractFamily(from parts: [String]) -> String? { - parts.first { ["opus", "sonnet", "haiku"].contains($0) } - } - - private static func extractVersion(from parts: [String]) -> String { - let numbers = parts.compactMap { Int($0) } - return numbers.count >= 2 - ? "\(numbers[0]).\(numbers[1])" - : numbers.first.map { "\($0)" } ?? "" - } - - private static func buildDisplayName(family: String?, version: String, fallback: String) -> String { - guard let family else { return fallback } - let name = "Claude \(family.capitalized)" - return version.isEmpty ? name : "\(name) \(version)" - } -} diff --git a/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift b/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift deleted file mode 100644 index fc8f0e2..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/ActionButtons.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// ActionButtons.swift -// Action buttons component for menu bar -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - ActionButtons - -struct ActionButtons: View { - @Environment(\.openWindow) private var openWindow - let settingsService: AppSettingsService - let onRefresh: () -> Void - let viewMode: MenuBarViewMode - - var body: some View { - HStack(spacing: 12) { - dashboardButton - refreshButton - menuBarOnlyButtons - } - .padding(.bottom, MenuBarTheme.Layout.actionButtonsBottomPadding) - } - - // MARK: - Button Components - - @ViewBuilder - private var dashboardButton: some View { - if viewMode == .menuBar { - Button("Main") { - openMainWindow() - } - .buttonStyle(MenuButtonStyle(style: .primary)) - .keyboardShortcut("1", modifiers: .command) - .help("Open the main window (⌘1)") - } - } - - private var refreshButton: some View { - Button("Refresh") { - onRefresh() - } - .buttonStyle(MenuButtonStyle(style: .primary)) - .keyboardShortcut("r", modifiers: .command) - .help("Refresh usage data (⌘R)") - } - - @ViewBuilder - private var menuBarOnlyButtons: some View { - if viewMode == .menuBar { - settingsMenuButton - Spacer() - quitButton - } - } - - private var settingsMenuButton: some View { - SettingsMenu(settingsService: settingsService) - .menuStyle(BorderlessButtonMenuStyle()) - .fixedSize() - .buttonStyle(MenuButtonStyle(style: .secondary)) - .help("Settings") - } - - private var quitButton: some View { - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .buttonStyle(MenuButtonStyle(style: .secondary)) - .keyboardShortcut("q", modifiers: .command) - .help("Quit the application (⌘Q)") - } - - // MARK: - Actions - - private func openMainWindow() { - // Capture screen at click time (before async operations) - let targetScreen = screenAtMouseLocation() - - // Always call openWindow - SwiftUI handles create vs activate - openWindow(id: "main") - NSApp.activate(ignoringOtherApps: true) - - // Wait for window to be created, then position - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - guard let window = findMainWindow() else { return } - - // Move window to current Space (not just current screen) - window.collectionBehavior.insert(.moveToActiveSpace) - - if window.isMiniaturized { - window.deminiaturize(nil) - } - window.makeKeyAndOrderFront(nil) - - // Set frame AFTER makeKeyAndOrderFront to override SwiftUI positioning - DispatchQueue.main.async { - centerWindow(window, on: targetScreen) - } - } - } -} - -// MARK: - Window Helpers - -@MainActor -private func findMainWindow() -> NSWindow? { - NSApp.windows.first { $0.title == AppMetadata.name } -} - -@MainActor -private func screenAtMouseLocation() -> NSScreen? { - let mouseLocation = NSEvent.mouseLocation - return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } - ?? NSScreen.main -} - -@MainActor -private func centerWindow(_ window: NSWindow, on screen: NSScreen?) { - guard let screen = screen else { return } - let screenFrame = screen.visibleFrame - let windowSize = window.frame.size - let x = screenFrame.midX - windowSize.width / 2 - let y = screenFrame.midY - windowSize.height / 2 - window.setFrame(NSRect(x: x, y: y, width: windowSize.width, height: windowSize.height), display: true) -} - -// MARK: - Supporting Types - -enum MenuBarViewMode { - case menuBar - case liveMetrics -} diff --git a/ClaudeCodeUsage/MenuBar/Components/GraphView.swift b/ClaudeCodeUsage/MenuBar/Components/GraphView.swift deleted file mode 100644 index 17c4cb0..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/GraphView.swift +++ /dev/null @@ -1,244 +0,0 @@ -// -// GraphView.swift -// Enhanced graph component with area fill and grid -// - -import SwiftUI - -// MARK: - Constants - -private enum Constants { - static let coordinatePadding: CGFloat = 4 - static let uniformNormalizedValue: Double = 0.5 -} - -// MARK: - GraphView - -struct GraphView: View { - let dataPoints: [Double] - let color: Color - let showDots: Bool = false - - var body: some View { - GeometryReader { geometry in - ZStack { - backgroundView - chartContentView(in: geometry) - } - } - .frame(height: MenuBarTheme.Layout.graphHeight) - } -} - -// MARK: - Background - -private extension GraphView { - var backgroundView: some View { - RoundedRectangle(cornerRadius: MenuBarTheme.Layout.graphCornerRadius) - .fill(MenuBarTheme.Colors.UI.background) - .overlay(backgroundBorder) - } - - var backgroundBorder: some View { - RoundedRectangle(cornerRadius: MenuBarTheme.Layout.graphCornerRadius) - .stroke(MenuBarTheme.Colors.UI.trackBorder, lineWidth: MenuBarTheme.Graph.strokeWidth) - } -} - -// MARK: - Chart Content - -private extension GraphView { - @ViewBuilder - func chartContentView(in geometry: GeometryProxy) -> some View { - if hasEnoughDataPoints { - let normalizedData = normalizedDataPoints - gridLinesView(in: geometry) - areaFillView(for: normalizedData, in: geometry) - lineGraphView(for: normalizedData, in: geometry) - dataDotsView(for: normalizedData, in: geometry) - } - } - - var hasEnoughDataPoints: Bool { - dataPoints.count > 1 - } - - @ViewBuilder - func dataDotsView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { - if showDots { - dataDots(for: normalizedData, in: geometry) - } - } -} - -// MARK: - Data Normalization - -private extension GraphView { - var normalizedDataPoints: [Double] { - guard !dataPoints.isEmpty else { return [] } - return normalizeToRange(dataPoints, range: dataRange) - } - - var dataRange: DataRange { - DataRange( - minimum: dataPoints.min() ?? 0.0, - maximum: dataPoints.max() ?? 1.0 - ) - } - - func normalizeToRange(_ points: [Double], range: DataRange) -> [Double] { - guard range.span > 0 else { - return uniformNormalizedValues(count: points.count) - } - return points.map { range.normalize($0) } - } - - func uniformNormalizedValues(count: Int) -> [Double] { - Array(repeating: Constants.uniformNormalizedValue, count: count) - } -} - -// MARK: - Data Range - -private struct DataRange { - let minimum: Double - let maximum: Double - - var span: Double { maximum - minimum } - - func normalize(_ value: Double) -> Double { - (value - minimum) / span - } -} - -// MARK: - Coordinate Calculation - -private extension GraphView { - func calculateCoordinates(for normalizedData: [Double], in size: CGSize) -> [CGPoint] { - guard normalizedData.count > 1 else { return [] } - let xStep = calculateXStep(for: normalizedData.count, width: size.width) - return normalizedData.enumerated().map { index, value in - calculatePoint(at: index, value: value, xStep: xStep, size: size) - } - } - - func calculateXStep(for count: Int, width: CGFloat) -> CGFloat { - width / CGFloat(count - 1) - } - - func calculatePoint(at index: Int, value: Double, xStep: CGFloat, size: CGSize) -> CGPoint { - let x = CGFloat(index) * xStep - let y = calculateY(for: value, height: size.height) - return CGPoint(x: x, y: y) - } - - func calculateY(for normalizedValue: Double, height: CGFloat) -> CGFloat { - let padding = Constants.coordinatePadding - let availableHeight = height - padding * 2 - return padding + (1.0 - normalizedValue) * availableHeight - } -} - -// MARK: - Grid Lines - -private extension GraphView { - func gridLinesView(in geometry: GeometryProxy) -> some View { - Path { path in - gridLineYPositions(in: geometry.size).forEach { y in - path.addHorizontalLine(at: y, width: geometry.size.width) - } - } - .stroke(MenuBarTheme.Colors.UI.gridLines, lineWidth: MenuBarTheme.Graph.strokeWidth) - } - - func gridLineYPositions(in size: CGSize) -> [CGFloat] { - (1.. CGFloat { - height * (CGFloat(index) / CGFloat(MenuBarTheme.Layout.gridLineCount)) - } -} - -// MARK: - Line Graph - -private extension GraphView { - func lineGraphView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { - Path { path in - path.addLineGraph(through: calculateCoordinates(for: normalizedData, in: geometry.size)) - } - .stroke(color, lineWidth: MenuBarTheme.Graph.lineWidth) - } -} - -// MARK: - Area Fill - -private extension GraphView { - func areaFillView(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { - Path { path in - path.addClosedArea( - through: calculateCoordinates(for: normalizedData, in: geometry.size), - bottomY: geometry.size.height, - width: geometry.size.width - ) - } - .fill(areaGradient) - } - - var areaGradient: LinearGradient { - LinearGradient( - colors: areaGradientColors, - startPoint: .top, - endPoint: .bottom - ) - } - - var areaGradientColors: [Color] { - [ - color.opacity(MenuBarTheme.Graph.areaGradientTopOpacity), - color.opacity(MenuBarTheme.Graph.areaGradientBottomOpacity) - ] - } -} - -// MARK: - Data Dots - -private extension GraphView { - func dataDots(for normalizedData: [Double], in geometry: GeometryProxy) -> some View { - let coordinates = calculateCoordinates(for: normalizedData, in: geometry.size) - return ForEach(coordinates.indices, id: \.self) { index in - dataDot(at: coordinates[index]) - } - } - - func dataDot(at point: CGPoint) -> some View { - Circle() - .fill(color) - .frame(width: MenuBarTheme.Layout.dataDotSize, height: MenuBarTheme.Layout.dataDotSize) - .position(point) - } -} - -// MARK: - Path Extensions - -private extension Path { - mutating func addHorizontalLine(at y: CGFloat, width: CGFloat) { - move(to: CGPoint(x: 0, y: y)) - addLine(to: CGPoint(x: width, y: y)) - } - - mutating func addLineGraph(through points: [CGPoint]) { - guard let first = points.first else { return } - move(to: first) - points.dropFirst().forEach { addLine(to: $0) } - } - - mutating func addClosedArea(through points: [CGPoint], bottomY: CGFloat, width: CGFloat) { - move(to: CGPoint(x: 0, y: bottomY)) - points.forEach { addLine(to: $0) } - addLine(to: CGPoint(x: width, y: bottomY)) - closeSubpath() - } -} diff --git a/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift b/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift deleted file mode 100644 index 09e585c..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/MetricRow.swift +++ /dev/null @@ -1,147 +0,0 @@ -// -// MetricRow.swift -// Metric display row with progress bar and optional trend graph -// - -import SwiftUI - -@available(macOS 13.0, *) -struct MetricRow: View { - let title: String - let value: String - let subvalue: String? - let percentage: Double - let segments: [ProgressSegment] - let trendData: [Double]? - let showWarning: Bool - - var body: some View { - VStack(alignment: .leading, spacing: MenuBarTheme.Layout.itemSpacing) { - headerRow - progressBarSection - } - .padding(.vertical, MenuBarTheme.Layout.verticalPadding) - } - - // MARK: - Header Row - - private var headerRow: some View { - HStack { - titleSection - Spacer() - graphSection - valueSection - } - } - - // MARK: - Graph Section - - @ViewBuilder - private var graphSection: some View { - if let data = trendData, data.count > 1 { - GraphView(dataPoints: data, color: percentageColor) - .frame( - width: MenuBarTheme.Layout.graphWidth, - height: MenuBarTheme.Layout.graphHeight - ) - .padding(.trailing, 6) - } - } - - // MARK: - Progress Bar Section - - private var progressBarSection: some View { - ProgressBar( - value: progressBarValue, - segments: segments, - showOverflow: isOverLimit - ) - } - - // MARK: - Pure Computations - - private var progressBarValue: Double { - min(percentage / 100.0, 1.5) - } - - private var isOverLimit: Bool { - percentage > 100 - } - - private var shouldShowWarningIcon: Bool { - showWarning && percentage >= 100 - } - - private var percentageColor: Color { - ColorService.colorForPercentage(percentage) - } - - // MARK: - Title Section - - private var titleSection: some View { - VStack(alignment: .leading, spacing: 2) { - titleLabel - percentageRow - } - } - - private var titleLabel: some View { - Text(title) - .font(MenuBarTheme.Typography.metricTitle) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - } - - private var percentageRow: some View { - HStack(spacing: 6) { - percentageLabel - warningIcon - } - } - - private var percentageLabel: some View { - Text(FormatterService.formatPercentage(percentage)) - .font(MenuBarTheme.Typography.metricValue) - .foregroundColor(percentageColor) - .monospacedDigit() - } - - @ViewBuilder - private var warningIcon: some View { - if shouldShowWarningIcon { - Image(systemName: "flame.fill") - .font(MenuBarTheme.Typography.warningIcon) - .foregroundColor(MenuBarTheme.Colors.Status.critical) - } - } - - // MARK: - Value Section - - private var valueSection: some View { - VStack(alignment: .trailing, spacing: 2) { - primaryValueLabel - subvalueLabel - } - .fixedSize(horizontal: false, vertical: true) - } - - private var primaryValueLabel: some View { - Text(value) - .font(MenuBarTheme.Typography.metricValue.weight(.medium)) - .foregroundColor(MenuBarTheme.Colors.UI.primaryText) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.8) - } - - @ViewBuilder - private var subvalueLabel: some View { - if let subvalue { - Text(subvalue) - .font(MenuBarTheme.Typography.metricSubvalue) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - .monospacedDigit() - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift b/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift deleted file mode 100644 index 4052486..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/ProgressBar.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// ProgressBar.swift -// Enhanced progress bar component with overflow support -// - -import SwiftUI - -@available(macOS 13.0, *) -struct ProgressBar: View { - let value: Double - let segments: [ProgressSegment] - let showOverflow: Bool - - var body: some View { - GeometryReader { geometry in - ZStack(alignment: .leading) { - backgroundTrack - progressFill(width: geometry.size.width) - overflowIndicator - } - } - .frame(height: MenuBarTheme.Layout.progressBarHeight) - } - - // MARK: - View Components - - private var backgroundTrack: some View { - RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius) - .fill(MenuBarTheme.Colors.UI.trackBackground) - .overlay(trackBorder) - } - - private var trackBorder: some View { - RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius) - .stroke(MenuBarTheme.Colors.UI.trackBorder, lineWidth: MenuBarTheme.Graph.strokeWidth) - } - - private func progressFill(width: CGFloat) -> some View { - HStack(spacing: 0) { - ForEach(segments.indices, id: \.self) { index in - segmentView(for: segments[index], containerWidth: width) - } - } - .clipShape(RoundedRectangle(cornerRadius: MenuBarTheme.Layout.progressBarCornerRadius)) - } - - @ViewBuilder - private func segmentView(for segment: ProgressSegment, containerWidth: CGFloat) -> some View { - let fillValue = segmentFillValue(for: segment) - if fillValue > 0 { - Rectangle() - .fill(segmentGradient(for: segment)) - .frame(width: min(fillValue * containerWidth, containerWidth)) - } - } - - @ViewBuilder - private var overflowIndicator: some View { - if shouldShowOverflowIndicator { - HStack { - Spacer() - Image(systemName: "exclamationmark.triangle.fill") - .font(MenuBarTheme.Typography.overflowIcon) - .foregroundColor(MenuBarTheme.Colors.Status.critical) - .offset(x: 2) - } - } - } - - // MARK: - Pure Functions - - private var shouldShowOverflowIndicator: Bool { - showOverflow && value > 1.0 - } - - private func segmentFillValue(for segment: ProgressSegment) -> Double { - let rangeLength = segment.range.upperBound - segment.range.lowerBound - let clampedValue = min(max(0, value - segment.range.lowerBound), rangeLength) - return clampedValue - } - - private func segmentGradient(for segment: ProgressSegment) -> LinearGradient { - LinearGradient( - colors: [ - segment.color.opacity(MenuBarTheme.Graph.progressGradientStartOpacity), - segment.color.opacity(MenuBarTheme.Graph.progressGradientEndOpacity) - ], - startPoint: .leading, - endPoint: .trailing - ) - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift b/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift deleted file mode 100644 index 3026665..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/SectionHeader.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// SectionHeader.swift -// Section header component with icon and optional badge -// - -import SwiftUI - -@available(macOS 13.0, *) -struct SectionHeader: View { - let title: String - let icon: String - let color: Color - let badge: String? - - var body: some View { - HStack(spacing: MenuBarTheme.Layout.itemSpacing) { - Image(systemName: icon) - .font(MenuBarTheme.Typography.sectionIcon) - .foregroundColor(color) - - Text(title.uppercased()) - .font(MenuBarTheme.Typography.sectionTitle) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - .kerning(MenuBarTheme.Typography.sectionTitleKerning) - - if let badge = badge { - badgeView(badge) - } - - Spacer() - } - .padding(.horizontal, MenuBarTheme.Layout.contentHorizontalPadding) - .padding(.vertical, MenuBarTheme.Layout.verticalPadding) - .background(MenuBarTheme.Colors.UI.sectionBackground) - } - - // MARK: - Badge View - private func badgeView(_ text: String) -> some View { - Text(text) - .font(MenuBarTheme.Typography.badgeText) - .foregroundColor(.white) - .padding(.horizontal, MenuBarTheme.Badge.horizontalPadding) - .padding(.vertical, MenuBarTheme.Badge.verticalPadding) - .background(color) - .cornerRadius(MenuBarTheme.Badge.cornerRadius) - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift b/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift deleted file mode 100644 index 45854f1..0000000 --- a/ClaudeCodeUsage/MenuBar/Components/SettingsMenu.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// SettingsMenu.swift -// Reusable settings menu component with proper separation of concerns -// - -import SwiftUI - -struct SettingsMenu: View { - let settingsService: AppSettingsService - @State private var showingError = false - @State private var lastError: AppSettingsError? - - let label: () -> Label - - init( - settingsService: AppSettingsService, - @ViewBuilder label: @escaping () -> Label - ) { - self.settingsService = settingsService - self.label = label - } - - var body: some View { - Menu { - // Open at Login Toggle - Toggle("Open at Login", isOn: openAtLoginBinding) - - Divider() - - // About - Button("About \(settingsService.appName)") { - settingsService.showAboutPanel() - } - } label: { - label() - } - .alert( - "Settings Error", - isPresented: $showingError, - presenting: lastError - ) { _ in - Button("OK", role: .cancel) { } - } message: { error in - Text(error.localizedDescription) - if let suggestion = error.recoverySuggestion { - Text(suggestion) - } - } - } - - private var openAtLoginBinding: Binding { - Binding( - get: { settingsService.isOpenAtLoginEnabled }, - set: { newValue in - Task { - let result = await settingsService.setOpenAtLogin(newValue) - if case .failure(let error) = result { - lastError = error - showingError = true - } - } - } - ) - } -} - -// MARK: - Convenience Initializers - -extension SettingsMenu { - /// Creates a settings menu with a gear icon - init(settingsService: AppSettingsService) where Label == Image { - self.init(settingsService: settingsService) { - Image(systemName: "gearshape.fill") - } - } -} - -extension SettingsMenu where Label == Text { - /// Creates a settings menu with text label - init(_ title: String, settingsService: AppSettingsService) { - self.init(settingsService: settingsService) { - Text(title) - } - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift b/ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift deleted file mode 100644 index d042f6e..0000000 --- a/ClaudeCodeUsage/MenuBar/Helpers/ColorService.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// ColorService.swift -// Business logic for color determination based on performance metrics -// - -import SwiftUI - -@available(macOS 13.0, *) -struct ColorService { - - // MARK: - Public API - - static func colorForPercentage(_ percentage: Double) -> Color { - color(for: percentage, using: percentageThresholds) - } - - static func colorForCostProgress(_ progress: Double) -> Color { - color(for: progress, using: costProgressThresholds) - } - - static func sessionTimeSegments() -> [ProgressSegment] { - timeSegmentRanges - } - - static func sessionTokenSegments() -> [ProgressSegment] { - tokenSegmentRanges - } - - static func singleColorSegment(color: Color) -> [ProgressSegment] { - [ProgressSegment(range: 0...1.0, color: color)] - } - - // MARK: - Threshold Lookup (Pure Function) - - private static func color( - for value: Double, - using thresholds: [ColorThreshold] - ) -> Color { - thresholds - .first { value < $0.upperBound } - .map(\.color) - ?? MenuBarTheme.Colors.Status.critical - } - - // MARK: - Threshold Definitions - - private static var percentageThresholds: [ColorThreshold] { - [ - ColorThreshold( - upperBound: MenuBarTheme.Thresholds.Percentage.low, - color: MenuBarTheme.Colors.Status.active - ), - ColorThreshold( - upperBound: MenuBarTheme.Thresholds.Percentage.high, - color: MenuBarTheme.Colors.Status.warning - ), - ] - } - - private static var costProgressThresholds: [ColorThreshold] { - [ - ColorThreshold( - upperBound: MenuBarTheme.Thresholds.Cost.normal, - color: MenuBarTheme.Colors.Status.active - ), - ColorThreshold( - upperBound: MenuBarTheme.Thresholds.Cost.critical, - color: MenuBarTheme.Colors.Status.warning - ), - ] - } - - // MARK: - Segment Definitions - - private static var timeSegmentRanges: [ProgressSegment] { - [ - ProgressSegment( - range: 0...MenuBarTheme.Thresholds.Sessions.timeSegments.low, - color: MenuBarTheme.Colors.ProgressSegments.green - ), - ProgressSegment( - range: MenuBarTheme.Thresholds.Sessions.timeSegments.low...MenuBarTheme.Thresholds.Sessions.timeSegments.medium, - color: MenuBarTheme.Colors.ProgressSegments.orange - ), - ProgressSegment( - range: MenuBarTheme.Thresholds.Sessions.timeSegments.medium...MenuBarTheme.Thresholds.Sessions.timeSegments.max, - color: MenuBarTheme.Colors.ProgressSegments.red - ), - ] - } - - private static var tokenSegmentRanges: [ProgressSegment] { - [ - ProgressSegment( - range: 0...MenuBarTheme.Thresholds.Sessions.tokenSegments.low, - color: MenuBarTheme.Colors.ProgressSegments.blue - ), - ProgressSegment( - range: MenuBarTheme.Thresholds.Sessions.tokenSegments.low...MenuBarTheme.Thresholds.Sessions.tokenSegments.medium, - color: MenuBarTheme.Colors.ProgressSegments.purple - ), - ProgressSegment( - range: MenuBarTheme.Thresholds.Sessions.tokenSegments.medium...MenuBarTheme.Thresholds.Sessions.tokenSegments.max, - color: MenuBarTheme.Colors.ProgressSegments.red - ), - ] - } -} - -// MARK: - Supporting Types - -@available(macOS 13.0, *) -private struct ColorThreshold { - let upperBound: Double - let color: Color -} - -@available(macOS 13.0, *) -struct ProgressSegment { - let range: ClosedRange - let color: Color -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift b/ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift deleted file mode 100644 index ac3db2b..0000000 --- a/ClaudeCodeUsage/MenuBar/Helpers/FormatterService.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// FormatterService.swift -// Formatting utilities for display values -// - -import Foundation - -struct FormatterService { - - // MARK: - Token Formatting - - static func formatTokenCount(_ count: Int) -> String { - TokenThreshold.format(count) - } - - // MARK: - Percentage Formatting - - static func formatPercentage(_ percentage: Double) -> String { - "\(Int(percentage))%" - } - - // MARK: - Time Duration Formatting - - static func formatTimeInterval(_ interval: TimeInterval, totalInterval: TimeInterval) -> String { - let hours = interval / TimeConstants.secondsPerHour - let totalHours = totalInterval / TimeConstants.secondsPerHour - return String(format: "%.1fh / %.0fh", hours, totalHours) - } - - /// Format countdown time like "Resets in 3 hr 24 min" - static func formatCountdown(_ interval: TimeInterval) -> String { - guard interval > 0 else { return "Resetting..." } - let hours = Int(interval / TimeConstants.secondsPerHour) - let minutes = Int((interval.truncatingRemainder(dividingBy: TimeConstants.secondsPerHour)) / TimeConstants.secondsPerMinute) - if hours > 0 { - return "Resets in \(hours) hr \(minutes) min" - } else { - return "Resets in \(minutes) min" - } - } - - // MARK: - Currency Formatting - - static func formatCurrency(_ amount: Double) -> String { - String(format: "$%.2f", amount) - } - - // MARK: - Rate Formatting - - static func formatTokenRate(_ tokensPerMinute: Int) -> String { - "\(formatTokenCount(tokensPerMinute)) tokens/min" - } - - static func formatCostRate(_ costPerHour: Double) -> String { - "\(formatCurrency(costPerHour))/hr" - } - - // MARK: - Session Count Formatting - - static func formatSessionCount(_ count: Int) -> String { - count > 0 ? "\(count) active" : "No active" - } - - // MARK: - Value with Limit Formatting - - static func formatValueWithLimit(_ current: T, limit: T) -> String { - "\(formatTokenCount(Int(current))) / \(formatTokenCount(Int(limit)))" - } - - // MARK: - Daily Average Formatting - - static func formatDailyAverage(_ average: Double) -> String { - formatCurrency(average) - } - - // MARK: - Large Number Formatting - - static func formatLargeNumber(_ number: Int) -> String { - formatTokenCount(number) - } - - // MARK: - Relative Time Formatting - - static func formatRelativeTime(_ date: Date?) -> String { - guard let date else { return "Never" } - let interval = Date().timeIntervalSince(date) - return RelativeTimeThreshold.format(interval) - } -} - -// MARK: - Token Threshold Configuration - -private enum TokenThreshold { - typealias Threshold = (minimum: Int, divisor: Double, suffix: String, format: String) - - static let thresholds: [Threshold] = [ - (minimum: 1_000_000, divisor: 1_000_000, suffix: "M", format: "%.1f"), - (minimum: 1_000, divisor: 1_000, suffix: "K", format: "%.1f") - ] - - static func format(_ count: Int) -> String { - thresholds - .first { count >= $0.minimum } - .map { formatWithThreshold(count, threshold: $0) } - ?? "\(count)" - } - - private static func formatWithThreshold(_ count: Int, threshold: Threshold) -> String { - let value = Double(count) / threshold.divisor - return String(format: "\(threshold.format)\(threshold.suffix)", value) - } -} - -// MARK: - Relative Time Threshold Configuration - -private enum RelativeTimeThreshold { - typealias Threshold = (maxInterval: TimeInterval, divisor: TimeInterval, suffix: String) - - static let thresholds: [Threshold] = [ - (maxInterval: TimeConstants.secondsPerMinute, divisor: 1, suffix: ""), - (maxInterval: TimeConstants.secondsPerHour, divisor: TimeConstants.secondsPerMinute, suffix: "m ago"), - (maxInterval: TimeConstants.secondsPerDay, divisor: TimeConstants.secondsPerHour, suffix: "h ago") - ] - - static func format(_ interval: TimeInterval) -> String { - thresholds - .first { interval < $0.maxInterval } - .map { formatWithThreshold(interval, threshold: $0) } - ?? formatDays(interval) - } - - private static func formatWithThreshold(_ interval: TimeInterval, threshold: Threshold) -> String { - threshold.suffix.isEmpty - ? "Just now" - : "\(Int(interval / threshold.divisor))\(threshold.suffix)" - } - - private static func formatDays(_ interval: TimeInterval) -> String { - "\(Int(interval / TimeConstants.secondsPerDay))d ago" - } -} - -// MARK: - Time Constants - -private enum TimeConstants { - static let secondsPerMinute: TimeInterval = 60 - static let secondsPerHour: TimeInterval = 3600 - static let secondsPerDay: TimeInterval = 86400 -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift b/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift deleted file mode 100644 index ca4f475..0000000 --- a/ClaudeCodeUsage/MenuBar/MenuBarContentView.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// MenuBar.swift -// Refactored professional menu bar UI with clean architecture -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Main Menu Bar Content View -struct MenuBarContentView: View { - @Environment(UsageStore.self) private var store - let settingsService: AppSettingsService - @FocusState private var focusedField: FocusField? - let viewMode: MenuBarViewMode - - enum FocusField: Hashable { - case refresh - case settings - case quit - } - - // MARK: - Initializers - init(settingsService: AppSettingsService, viewMode: MenuBarViewMode = .menuBar) { - self.settingsService = settingsService - self.viewMode = viewMode - } - - var body: some View { - VStack(spacing: 0) { - liveSessionSection - usageSection - costSection - actionsSection - } - .frame(width: MenuBarTheme.Layout.menuBarWidth) - .background(MenuBarTheme.Colors.UI.background) - .focusable() - .onKeyPress { handleKeyPress($0) } - } - - // MARK: - Sections - - @ViewBuilder - private var liveSessionSection: some View { - if let session = store.activeSession { - let _ = session // Suppress unused warning - SectionHeader( - title: "Live Session", - icon: "dot.radiowaves.left.and.right", - color: MenuBarTheme.Colors.Sections.liveSession, - badge: "ACTIVE" - ) - SessionMetricsSection() - .padding(.horizontal, 12) - .padding(.vertical, 4) - sectionDivider - .padding(.horizontal, 12) - } - } - - private var usageSection: some View { - Group { - SectionHeader( - title: "Usage", - icon: "chart.bar.fill", - color: MenuBarTheme.Colors.Sections.usage, - badge: nil - ) - UsageMetricsSection() - .padding(.horizontal, 12) - .padding(.vertical, 4) - sectionDivider - .padding(.horizontal, 12) - } - } - - private var costSection: some View { - Group { - SectionHeader( - title: "Cost", - icon: "dollarsign.circle.fill", - color: MenuBarTheme.Colors.Sections.cost, - badge: nil - ) - CostMetricsSection() - .padding(.horizontal, 12) - .padding(.vertical, 4) - sectionDivider - .padding(.horizontal, 12) - } - } - - private var actionsSection: some View { - ActionButtons(settingsService: settingsService, onRefresh: handleRefresh, viewMode: viewMode) - .padding(.horizontal, 12) - .padding(.bottom, 8) - } - - // MARK: - Keyboard Handling - - private func handleKeyPress(_ press: KeyPress) -> KeyPress.Result { - switch press.key { - case .tab: - press.modifiers.contains(.shift) ? switchFocusPrevious() : switchFocusNext() - return .handled - case .escape: - if viewMode == .menuBar { NSApp.hide(nil) } - return .handled - case KeyEquivalent("r") where press.modifiers.contains(.command): - handleRefresh() - return .handled - default: - return .ignored - } - } - - // MARK: - Focus Navigation - private func switchFocusNext() { - switch focusedField { - case .refresh: - focusedField = .settings - case .settings: - focusedField = .quit - case .quit, nil: - focusedField = .refresh - } - } - - private func switchFocusPrevious() { - switch focusedField { - case .refresh: - focusedField = .quit - case .settings: - focusedField = .refresh - case .quit, nil: - focusedField = .settings - } - } - - // MARK: - UI Elements - private var sectionDivider: some View { - Divider() - .padding(.vertical, MenuBarTheme.Layout.dividerVerticalPadding) - } - - // MARK: - Actions - private func handleRefresh() { - Task { - await store.loadData() - } - } -} - -// MARK: - Backward Compatibility Aliases -// These maintain compatibility with existing code that may reference the old component names -typealias ImprovedProgressBar = ProgressBar -typealias EnhancedGraphView = GraphView -typealias ImprovedSectionHeader = SectionHeader diff --git a/ClaudeCodeUsage/MenuBar/MenuBarScene.swift b/ClaudeCodeUsage/MenuBar/MenuBarScene.swift deleted file mode 100644 index 0ea065a..0000000 --- a/ClaudeCodeUsage/MenuBar/MenuBarScene.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// MenuBarScene.swift -// Menu bar scene and supporting views -// - -import SwiftUI -import ClaudeUsageCore - -// MARK: - Menu Bar Scene - -public struct MenuBarScene: Scene { - let store: UsageStore - let settingsService: AppSettingsService - let lifecycleManager: AppLifecycleManager - @State private var hasInitialized = false - - public init(store: UsageStore, settingsService: AppSettingsService, lifecycleManager: AppLifecycleManager) { - self.store = store - self.settingsService = settingsService - self.lifecycleManager = lifecycleManager - } - - public var body: some Scene { - MenuBarExtra { - menuContent - } label: { - menuLabel - } - .menuBarExtraStyle(.window) - } - - private var menuContent: some View { - MenuBarContentView(settingsService: settingsService) - .environment(store) - } - - private var menuLabel: some View { - MenuBarLabel(store: store) - .environment(store) - .task { await initializeOnce() } - .contextMenu { contextMenu } - } - - private var contextMenu: some View { - MenuBarContextMenu(settingsService: settingsService) - .environment(store) - } - - private func initializeOnce() async { - guard !hasInitialized else { return } - hasInitialized = true - lifecycleManager.configure(with: store) - await store.initializeIfNeeded() - } -} - -// MARK: - Menu Bar Label - -struct MenuBarLabel: View { - let store: UsageStore - - var body: some View { - HStack(spacing: 4) { - iconView - costText - } - } - - private var iconView: some View { - Image(systemName: appearance.icon) - .foregroundColor(appearance.color) - } - - private var costText: some View { - Text(store.formattedTodaysCost) - .font(.system(.body, design: .monospaced)) - } - - private var appearance: MenuBarAppearance { - MenuBarAppearance.from(store: store) - } -} - -// MARK: - Menu Bar Context Menu - -struct MenuBarContextMenu: View { - @Environment(UsageStore.self) private var store - let settingsService: AppSettingsService - - var body: some View { - Group { - refreshSection - Divider() - statusSection - Divider() - actionsSection - Divider() - quitButton - } - } - - private var refreshSection: some View { - Button("Refresh") { - Task { await store.loadData() } - } - } - - private var statusSection: some View { - Group { - sessionIndicator - Text("Today: \(store.formattedTodaysCost)") - } - } - - @ViewBuilder - private var sessionIndicator: some View { - if let session = store.activeSession, session.isActive { - Label("Session Active", systemImage: "dot.radiowaves.left.and.right") - .foregroundColor(.green) - } - } - - private var actionsSection: some View { - Group { - Button("Main") { WindowActions.showMainWindow() } - OpenAtLoginToggle(settingsService: settingsService) - } - } - - private var quitButton: some View { - Button("Quit") { NSApplication.shared.terminate(nil) } - } -} - -// MARK: - Menu Bar Appearance - -@MainActor -enum MenuBarAppearance { - case active - case warning - case normal - - var icon: String { - switch self { - case .active: "dollarsign.circle.fill" - case .warning: "exclamationmark.triangle.fill" - case .normal: "dollarsign.circle" - } - } - - var color: Color { - switch self { - case .active: .green - case .warning: .orange - case .normal: .primary - } - } - - static func from(store: UsageStore) -> MenuBarAppearance { - if store.hasActiveSession { return .active } - if store.isOverBudget { return .warning } - return .normal - } -} - -// MARK: - UsageStore Appearance Helpers - -extension UsageStore { - var hasActiveSession: Bool { - activeSession?.isActive == true - } - - var isOverBudget: Bool { - todaysCost > dailyCostThreshold - } -} - -// MARK: - Preview - -#if DEBUG -struct MenuBarPreview: View { - @State private var store = UsageStore() - - var body: some View { - content - .frame(height: 500) - .task { await store.loadData() } - } - - @ViewBuilder - private var content: some View { - if store.state.hasLoaded { - MenuBarContentView(settingsService: AppSettingsService()) - .environment(store) - } else { - ProgressView("Loading...") - .frame(width: MenuBarTheme.Layout.menuBarWidth, height: 200) - } - } -} - -struct MenuBarLabelPreview: View { - @State private var store = UsageStore() - - var body: some View { - content - .frame(width: 200, height: 100) - .task { await store.loadData() } - } - - @ViewBuilder - private var content: some View { - if store.state.hasLoaded { - MenuBarLabel(store: store) - .padding() - .background(Color(nsColor: .windowBackgroundColor)) - .cornerRadius(8) - } else { - ProgressView() - } - } -} - -#Preview("Menu Bar Content", traits: .sizeThatFitsLayout) { - MenuBarPreview() -} - -#Preview("Menu Bar Label", traits: .sizeThatFitsLayout) { - MenuBarLabelPreview() -} -#endif - -// MARK: - Window Actions - -public enum WindowActions { - @MainActor - public static func showMainWindow() { - let targetScreen = captureScreenAtMouseLocation() - activateApp() - findAndShowWindow(on: targetScreen) - } - - @MainActor - private static func captureScreenAtMouseLocation() -> NSScreen? { - let mouseLocation = NSEvent.mouseLocation - return NSScreen.screens.first { NSMouseInRect(mouseLocation, $0.frame, false) } - ?? NSScreen.main - } - - @MainActor - private static func activateApp() { - NSApp.activate(ignoringOtherApps: true) - } - - @MainActor - private static func findAndShowWindow(on targetScreen: NSScreen?) { - guard let window = NSApp.windows.first(where: { $0.title == AppMetadata.name }) else { return } - moveToActiveSpace(window) - restoreIfMinimized(window) - window.makeKeyAndOrderFront(nil) - DispatchQueue.main.async { centerWindow(window, on: targetScreen) } - } - - @MainActor - private static func moveToActiveSpace(_ window: NSWindow) { - window.collectionBehavior.insert(.moveToActiveSpace) - } - - @MainActor - private static func restoreIfMinimized(_ window: NSWindow) { - if window.isMiniaturized { window.deminiaturize(nil) } - } - - @MainActor - private static func centerWindow(_ window: NSWindow, on screen: NSScreen?) { - guard let screen else { return } - let frame = screen.visibleFrame - let size = window.frame.size - let origin = CGPoint(x: frame.midX - size.width / 2, y: frame.midY - size.height / 2) - window.setFrame(NSRect(origin: origin, size: size), display: true) - } -} diff --git a/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift deleted file mode 100644 index c5f1e9e..0000000 --- a/ClaudeCodeUsage/MenuBar/Sections/CostMetricsSection.swift +++ /dev/null @@ -1,169 +0,0 @@ -import SwiftUI -import Charts -import ClaudeUsageCore - -struct CostMetricsSection: View { - @Environment(UsageStore.self) private var store - - @State private var cachedTodaysCostColor: Color = MenuBarTheme.Colors.Status.normal - @State private var lastCostProgress: Double = 0 - - var body: some View { - VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { - todaysCostView - summaryStatsViewIfAvailable - } - .onChange(of: store.todaysCostProgress) { oldValue, newValue in - updateCostColorIfChanged(oldValue: oldValue, newValue: newValue) - } - .onAppear(perform: initializeCachedValues) - } - - private var summaryStatsViewIfAvailable: some View { - Group { - if let stats = store.stats { - summaryStatsView(stats) - } - } - } - - private func updateCostColorIfChanged(oldValue: Double, newValue: Double) { - guard abs(oldValue - newValue) > 0.01 else { return } - cachedTodaysCostColor = ColorService.colorForCostProgress(newValue) - lastCostProgress = newValue - } - - private func initializeCachedValues() { - cachedTodaysCostColor = ColorService.colorForCostProgress(store.todaysCostProgress) - lastCostProgress = store.todaysCostProgress - } -} - -// MARK: - Today's Cost View - -private extension CostMetricsSection { - var todaysCostView: some View { - HStack(spacing: 12) { - todaysCostLabel - Spacer() - hourlyCostChartIfAvailable - } - .padding(.vertical, MenuBarTheme.Layout.verticalPadding) - } - - var todaysCostLabel: some View { - VStack(alignment: .leading, spacing: 2) { - Text("Today") - .font(MenuBarTheme.Typography.metricTitle) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - costValueWithWarning - } - .layoutPriority(1) - } - - var costValueWithWarning: some View { - HStack(spacing: 4) { - Text(store.formattedTodaysCost) - .font(MenuBarTheme.Typography.metricValue) - .foregroundColor(cachedTodaysCostColor) - .monospacedDigit() - .lineLimit(1) - .fixedSize(horizontal: true, vertical: false) - budgetExceededWarningIfNeeded - } - } - - @ViewBuilder - var budgetExceededWarningIfNeeded: some View { - if store.todaysCostProgress > 1.0 { - Image(systemName: "flame.fill") - .font(MenuBarTheme.Typography.warningIcon) - .foregroundColor(MenuBarTheme.Colors.Status.critical) - } - } - - @ViewBuilder - var hourlyCostChartIfAvailable: some View { - if !store.todayHourlyCosts.isEmpty { - HourlyCostChartSimple(hourlyData: store.todayHourlyCosts) - } - } -} - -// MARK: - Summary Stats View - -private extension CostMetricsSection { - func summaryStatsView(_ stats: UsageStats) -> some View { - HStack { - totalCostStat(stats) - Spacer() - dailyAverageStat - } - .padding(.bottom, MenuBarTheme.Layout.verticalPadding) - } - - func totalCostStat(_ stats: UsageStats) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text("Total") - .font(MenuBarTheme.Typography.summaryLabel) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - Text(stats.totalCost.asCurrency) - .font(MenuBarTheme.Typography.summaryValue) - .monospacedDigit() - } - } - - var dailyAverageStat: some View { - VStack(alignment: .trailing, spacing: 2) { - Text("7d Avg") - .font(MenuBarTheme.Typography.summaryLabel) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - Text(FormatterService.formatDailyAverage(store.averageDailyCost)) - .font(MenuBarTheme.Typography.summaryValue) - .monospacedDigit() - } - } -} - -// MARK: - Y-Axis Labels Component - -private struct YAxisLabels: View { - let maxValue: Double - - private static let labelMultipliers: [Double] = [1.0, 0.75, 0.5, 0.25, 0.0] - - var body: some View { - VStack(alignment: .trailing, spacing: 0) { - ForEach(Self.labelMultipliers, id: \.self) { multiplier in - axisLabel(value: maxValue * multiplier) - if multiplier != 0.0 { - Spacer() - } - } - } - .padding(.bottom, 12) - } - - private func axisLabel(value: Double) -> some View { - Text(CostFormat.format(value)) - .font(.system(size: 8, weight: .regular, design: .monospaced)) - .foregroundColor(.gray) - } -} - -// MARK: - Cost Format - -private enum CostFormat { - static func format(_ value: Double) -> String { - switch value { - case 0: - "$0" - case ..<1: - String(format: "$%.2f", value) - case ..<10: - String(format: "$%.1f", value) - default: - String(format: "$%.0f", value) - } - } -} diff --git a/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift deleted file mode 100644 index b46571c..0000000 --- a/ClaudeCodeUsage/MenuBar/Sections/SessionMetricsSection.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// SessionMetricsSection.swift -// Live session metrics section component -// - -import SwiftUI -import ClaudeUsageCore - -struct SessionMetricsSection: View { - @Environment(UsageStore.self) private var store - - var body: some View { - VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { - if let session = store.activeSession { - // Time since session started / 5h window - MetricRow( - title: "Time", - value: FormatterService.formatTimeInterval( - Date().timeIntervalSince(session.startTime), - totalInterval: session.endTime.timeIntervalSince(session.startTime) - ), - subvalue: nil, - percentage: store.sessionTimeProgress * 100, - segments: ColorService.sessionTimeSegments(), - trendData: nil, - showWarning: false - ) - } - } - } -} diff --git a/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift b/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift deleted file mode 100644 index 8a0a5da..0000000 --- a/ClaudeCodeUsage/MenuBar/Sections/UsageMetricsSection.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// UsageMetricsSection.swift -// Usage metrics section component -// - -import SwiftUI -import ClaudeUsageCore - -struct UsageMetricsSection: View { - @Environment(UsageStore.self) private var store - - var body: some View { - VStack(spacing: MenuBarTheme.Layout.sectionSpacing) { - if store.stats != nil { - // Token usage - show raw count only (no fake percentage) - // Claude's actual rate limit is not exposed in usage data - if let session = store.activeSession { - TokenDisplay(tokens: session.tokens.total) - } - - // Burn rate - if let burnRate = store.burnRate { - burnRateView(burnRate) - } - } - } - } - - // MARK: - Burn Rate View - private func burnRateView(_ burnRate: BurnRate) -> some View { - HStack { - Label( - FormatterService.formatTokenRate(burnRate.tokensPerMinute), - systemImage: "flame.fill" - ) - .font(MenuBarTheme.Typography.burnRateLabel) - .foregroundColor(MenuBarTheme.Colors.Status.warning) - - Spacer() - - Text(FormatterService.formatCostRate(burnRate.costPerHour)) - .font(MenuBarTheme.Typography.burnRateValue) - .foregroundColor(MenuBarTheme.Colors.Status.warning) - .monospacedDigit() - } - .padding(.bottom, MenuBarTheme.Layout.verticalPadding) - } -} - -// MARK: - Token Display (no fake percentage) - -private struct TokenDisplay: View { - let tokens: Int - - var body: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Tokens") - .font(MenuBarTheme.Typography.metricTitle) - .foregroundColor(MenuBarTheme.Colors.UI.secondaryText) - } - - Spacer() - - Text(FormatterService.formatTokenCount(tokens)) - .font(MenuBarTheme.Typography.metricValue.weight(.medium)) - .foregroundColor(MenuBarTheme.Colors.UI.primaryText) - .monospacedDigit() - } - .padding(.vertical, MenuBarTheme.Layout.verticalPadding) - } -} diff --git a/ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift b/ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift deleted file mode 100644 index 6b675e5..0000000 --- a/ClaudeCodeUsage/MenuBar/Theme/MenuBarStyles.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// MenuBarStyles.swift -// Reusable button styles for the menu bar -// - -import SwiftUI - -// MARK: - Menu Button Style -@available(macOS 13.0, *) -struct MenuButtonStyle: ButtonStyle { - enum Style { - case primary, secondary - } - - let style: Style - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(MenuBarTheme.Typography.actionButton) - .foregroundColor(foregroundColor) - .padding(.horizontal, MenuBarTheme.Button.horizontalPadding) - .padding(.vertical, MenuBarTheme.Button.verticalPadding) - .background( - RoundedRectangle(cornerRadius: MenuBarTheme.Button.cornerRadius) - .fill(backgroundColor) - .overlay( - RoundedRectangle(cornerRadius: MenuBarTheme.Button.cornerRadius) - .stroke(borderColor, lineWidth: MenuBarTheme.Button.borderWidth) - ) - ) - .scaleEffect(configuration.isPressed ? MenuBarTheme.Animation.scalePressed : MenuBarTheme.Animation.scaleNormal) - .animation(MenuBarTheme.Animation.buttonPress, value: configuration.isPressed) - } - - private var foregroundColor: Color { - switch style { - case .primary: - return MenuBarTheme.Colors.UI.primaryButtonText - case .secondary: - return MenuBarTheme.Colors.UI.secondaryButtonText - } - } - - private var backgroundColor: Color { - switch style { - case .primary: - return MenuBarTheme.Colors.UI.primaryButtonBackground - case .secondary: - return MenuBarTheme.Colors.UI.secondaryButtonBackground - } - } - - private var borderColor: Color { - switch style { - case .primary: - return MenuBarTheme.Colors.UI.primaryButtonBorder - case .secondary: - return MenuBarTheme.Colors.UI.secondaryButtonBorder - } - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift b/ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift deleted file mode 100644 index 36c4aa4..0000000 --- a/ClaudeCodeUsage/MenuBar/Theme/MenuBarTheme.swift +++ /dev/null @@ -1,167 +0,0 @@ -// -// MenuBarTheme.swift -// Centralized theme and design constants for the menu bar -// - -import SwiftUI - -@available(macOS 13.0, *) -struct MenuBarTheme { - - // MARK: - Layout Constants - struct Layout { - // Menu Bar Width - static let menuBarWidth: CGFloat = 360 - - // Padding - static let horizontalPadding: CGFloat = 20 - static let contentHorizontalPadding: CGFloat = 12 // Reduced padding for content sections - static let verticalPadding: CGFloat = 10 - - // Spacing - static let sectionSpacing: CGFloat = 14 - static let itemSpacing: CGFloat = 8 - static let dividerVerticalPadding: CGFloat = 6 - static let actionButtonsBottomPadding: CGFloat = 14 - - // Progress Bar - static let progressBarHeight: CGFloat = 10 - static let progressBarCornerRadius: CGFloat = 4 - - // Graph - static let graphHeight: CGFloat = 45 - static let graphCornerRadius: CGFloat = 6 - static let graphWidth: CGFloat = 100 - static let largeGraphWidth: CGFloat = 200 - static let costGraphHeight: CGFloat = 50 - - // Dots - static let dataDotSize: CGFloat = 4 - static let gridLineCount: Int = 4 - } - - // MARK: - Typography - struct Typography { - // Regular Typography - static let sectionTitle = Font.system(size: 10, weight: .semibold) - static let sectionTitleKerning: CGFloat = 0.8 - - static let metricTitle = Font.system(size: 11) - static let metricValue = Font.system(size: 15, weight: .semibold, design: .rounded) - static let metricSubvalue = Font.system(size: 9) - static let summaryValue = Font.system(size: 12, weight: .medium) - static let summaryLabel = Font.system(size: 10) - - static let burnRateLabel = Font.system(size: 11) - static let burnRateValue = Font.system(size: 11, weight: .medium) - - static let actionButton = Font.system(size: 11, weight: .medium) - - static let badgeText = Font.system(size: 9, weight: .medium) - static let sectionIcon = Font.system(size: 12, weight: .medium) - static let warningIcon = Font.system(size: 11) - static let overflowIcon = Font.system(size: 8) - } - - // MARK: - Colors - struct Colors { - // Progress Bar Segments - struct ProgressSegments { - static let green = Color.green - static let orange = Color.orange - static let red = Color.red - static let blue = Color.blue - static let purple = Color.purple - } - - // Status Colors - struct Status { - static let active = Color.green - static let warning = Color.orange - static let critical = Color.red - static let normal = Color.blue - } - - // UI Colors - struct UI { - static let background = Color(NSColor.controlBackgroundColor) - static let secondaryText = Color.secondary - static let primaryText = Color.primary - - static let trackBackground = Color.gray.opacity(0.1) - static let trackBorder = Color.gray.opacity(0.2) - static let sectionBackground = Color.gray.opacity(0.05) - - static let primaryButtonBackground = Color.blue.opacity(0.1) - static let primaryButtonBorder = Color.blue.opacity(0.3) - static let primaryButtonText = Color.blue - - static let secondaryButtonBackground = Color.gray.opacity(0.1) - static let secondaryButtonBorder = Color.gray.opacity(0.2) - static let secondaryButtonText = Color.primary - - static let gridLines = Color.gray.opacity(0.1) - } - - // Section Colors - struct Sections { - static let liveSession = Color.green - static let usage = Color.blue - static let system = Color.purple - static let cost = Color.purple - } - } - - // MARK: - Performance Thresholds - struct Thresholds { - struct Percentage { - static let low: Double = 60.0 - static let medium: Double = 80.0 - static let high: Double = 100.0 - } - - struct Cost { - static let normal: Double = 0.6 - static let warning: Double = 0.8 - static let critical: Double = 1.0 - } - - struct Sessions { - static let timeSegments = (low: 0.7, medium: 0.9, max: 1.5) - static let tokenSegments = (low: 0.6, medium: 0.85, max: 1.5) - } - } - - // MARK: - Animation - struct Animation { - static let buttonPress = SwiftUI.Animation.easeInOut(duration: 0.1) - static let scalePressed: CGFloat = 0.95 - static let scaleNormal: CGFloat = 1.0 - } - - // MARK: - Button Styles - struct Button { - static let horizontalPadding: CGFloat = 16 - static let verticalPadding: CGFloat = 6 - static let cornerRadius: CGFloat = 6 - static let borderWidth: CGFloat = 1 - } - - // MARK: - Badge - struct Badge { - static let horizontalPadding: CGFloat = 6 - static let verticalPadding: CGFloat = 2 - static let cornerRadius: CGFloat = 4 - } - - // MARK: - Graph Properties - struct Graph { - static let lineWidth: CGFloat = 2 - static let strokeWidth: CGFloat = 0.5 - static let minimumRange: Double = 0.01 - static let areaGradientTopOpacity: Double = 0.3 - static let areaGradientBottomOpacity: Double = 0.05 - static let progressGradientStartOpacity: Double = 0.8 - static let progressGradientEndOpacity: Double = 1.0 - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/Settings/AppSettingsService.swift b/ClaudeCodeUsage/Settings/AppSettingsService.swift deleted file mode 100644 index 7cec52a..0000000 --- a/ClaudeCodeUsage/Settings/AppSettingsService.swift +++ /dev/null @@ -1,151 +0,0 @@ -// -// AppSettingsService.swift -// Centralized settings management with proper architecture -// - -import Foundation -import ServiceManagement -import SwiftUI -import Observation -import OSLog - -private let logger = Logger(subsystem: "com.claudecodeusage", category: "AppSettings") - -// MARK: - Protocol - -@MainActor -protocol AppSettingsServiceProtocol: AnyObject { - var isOpenAtLoginEnabled: Bool { get } - func setOpenAtLogin(_ enabled: Bool) async -> Result - func showAboutPanel() -} - -// MARK: - Main Implementation - -@Observable -@MainActor -public final class AppSettingsService: AppSettingsServiceProtocol { - public private(set) var isOpenAtLoginEnabled: Bool = false - - public var appName: String { AppMetadata.name } - - public init() { - refreshLoginStatus() - } - - // MARK: - Public API (High-Level Intent) - - public func setOpenAtLogin(_ enabled: Bool) async -> Result { - guard #available(macOS 13.0, *) else { - return .failure(.unsupportedOS) - } - - guard needsChange(to: enabled) else { - return .success(()) - } - - do { - try await updateServiceRegistration(enabled: enabled) - refreshLoginStatus() - return .success(()) - } catch { - logger.error("Failed to set launch at login: \(error.localizedDescription)") - return .failure(.serviceManagementFailed(error)) - } - } - - public func showAboutPanel() { - NSApp.orderFrontStandardAboutPanel(options: aboutPanelOptions) - } - - // MARK: - Private Helpers (Infrastructure) - - @available(macOS 13.0, *) - private func needsChange(to enabled: Bool) -> Bool { - let currentlyEnabled = SMAppService.mainApp.status == .enabled - return currentlyEnabled != enabled - } - - @available(macOS 13.0, *) - private func updateServiceRegistration(enabled: Bool) async throws { - if enabled { - try SMAppService.mainApp.register() - } else { - try await SMAppService.mainApp.unregister() - } - } - - private func refreshLoginStatus() { - guard #available(macOS 13.0, *) else { - isOpenAtLoginEnabled = false - return - } - isOpenAtLoginEnabled = SMAppService.mainApp.status == .enabled - } - - private var aboutPanelOptions: [NSApplication.AboutPanelOptionKey: Any] { - [ - .applicationName: AppMetadata.name, - .applicationVersion: AppMetadata.version, - .credits: NSAttributedString( - string: AppMetadata.credits, - attributes: [.font: NSFont.systemFont(ofSize: 11)] - ) - ] - } -} - -// MARK: - Mock for Testing - -#if DEBUG -@Observable -@MainActor -final class MockAppSettingsService: AppSettingsServiceProtocol { - private(set) var isOpenAtLoginEnabled: Bool = false - - func setOpenAtLogin(_ enabled: Bool) async -> Result { - isOpenAtLoginEnabled = enabled - return .success(()) - } - - func showAboutPanel() { - logger.debug("About panel shown") - } -} -#endif - -// MARK: - Supporting Types - -public enum AppSettingsError: LocalizedError { - case serviceManagementFailed(Error) - case permissionDenied - case unsupportedOS - - public var errorDescription: String? { - switch self { - case .serviceManagementFailed(let error): - return "Failed to update launch settings: \(error.localizedDescription)" - case .permissionDenied: - return "Permission denied. Please check System Settings > Login Items." - case .unsupportedOS: - return "Open at Login requires macOS 13.0 or later" - } - } - - public var recoverySuggestion: String? { - switch self { - case .serviceManagementFailed: - return "Try again or check System Settings > Login Items" - case .permissionDenied: - return "Grant permission in System Settings" - case .unsupportedOS: - return "Update to macOS 13.0 or later" - } - } -} - -public enum AppMetadata { - public static let name = "Claude Usage" - public static let version = "1.0.0" - public static let credits = "Claude Code Usage Tracking" -} diff --git a/ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift b/ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift deleted file mode 100644 index c1ce961..0000000 --- a/ClaudeCodeUsage/Settings/OpenAtLoginToggle.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// OpenAtLoginToggle.swift -// Menu bar toggle for Open at Login setting -// - -import SwiftUI - -public struct OpenAtLoginToggle: View { - let settingsService: AppSettingsService - @State private var isHovered = false - - public init(settingsService: AppSettingsService) { - self.settingsService = settingsService - } - - public var body: some View { - HStack(spacing: Layout.spacing) { - checkboxIcon - labelText - Spacer() - } - .padding(.vertical, Layout.verticalPadding) - .padding(.horizontal, Layout.horizontalPadding) - .background(hoverBackground) - .onHover { isHovered = $0 } - .onTapGesture(perform: toggleSetting) - .help("Launch \(AppMetadata.name) automatically when you log in") - } -} - -// MARK: - Subviews - -private extension OpenAtLoginToggle { - var checkboxIcon: some View { - Image(systemName: checkboxIconName) - .foregroundColor(checkboxColor) - .font(.system(size: Layout.iconSize)) - .animation(Layout.hoverAnimation, value: isEnabled) - .animation(Layout.hoverAnimation, value: isHovered) - } - - var labelText: some View { - Text("Open at Login") - .font(MenuBarTheme.Typography.actionButton) - .foregroundColor(labelColor) - } - - var hoverBackground: some View { - RoundedRectangle(cornerRadius: Layout.cornerRadius) - .fill(isHovered ? Color.gray.opacity(Layout.hoverOpacity) : .clear) - } -} - -// MARK: - Computed State - -private extension OpenAtLoginToggle { - var isEnabled: Bool { - settingsService.isOpenAtLoginEnabled - } - - var checkboxIconName: String { - isEnabled ? "checkmark.square.fill" : "square" - } - - var checkboxColor: Color { - isEnabled ? .accentColor : (isHovered ? .primary : .secondary) - } - - var labelColor: Color { - isHovered ? .primary : .secondary - } -} - -// MARK: - Actions - -private extension OpenAtLoginToggle { - func toggleSetting() { - Task { - _ = await settingsService.setOpenAtLogin(!isEnabled) - } - } -} - -// MARK: - Layout Constants - -private extension OpenAtLoginToggle { - enum Layout { - static let spacing: CGFloat = 8 - static let iconSize: CGFloat = 12 - static let verticalPadding: CGFloat = 6 - static let horizontalPadding: CGFloat = 8 - static let cornerRadius: CGFloat = 4 - static let hoverOpacity: Double = 0.1 - static let hoverAnimation = Animation.easeInOut(duration: 0.1) - } -} diff --git a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift deleted file mode 100644 index 70c5510..0000000 --- a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyChartModels.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// HourlyChartModels.swift -// Data models for TDD hourly chart functionality -// - -import Foundation -import ClaudeUsageCore - -// MARK: - TDD Hourly Chart Data Models - -/// Represents a single bar in the TDD hourly chart -public struct HourlyBar: Identifiable, Equatable { - /// Unique identifier based on hour - public var id: String { "hour-\(hour)" } - - /// Hour of the day (0-23) - public let hour: Int - - /// Total cost for this hour - public let cost: Double - - /// Number of entries for this hour - public let entryCount: Int - - /// Pre-formatted cost string for display - public var formattedCost: String { - cost.asCurrency - } - - /// Whether this hour has no usage - public var isEmpty: Bool { - cost == 0 && entryCount == 0 - } - - public init(hour: Int, cost: Double, entryCount: Int) { - self.hour = hour - self.cost = cost - self.entryCount = entryCount - } -} - -/// Complete dataset for TDD hourly chart visualization -public struct HourlyChartDataset: Equatable { - /// Array of 24 bars representing each hour - public let bars: [HourlyBar] - - /// Total cost across all hours - public let totalCost: Double - - /// Peak usage hour (hour with highest cost) - public let peakHour: Int? - - /// Peak cost value - public let peakCost: Double - - /// Whether an error occurred during data generation - public let hasError: Bool - - /// Error message if hasError is true - public let errorMessage: String - - public init( - bars: [HourlyBar], - totalCost: Double = 0, - peakHour: Int? = nil, - peakCost: Double = 0, - hasError: Bool = false, - errorMessage: String = "" - ) { - self.bars = bars - self.totalCost = totalCost == 0 ? bars.reduce(0) { $0 + $1.cost } : totalCost - - // Calculate peak hour and cost if not provided - if let peakHour = peakHour { - self.peakHour = peakHour - self.peakCost = peakCost - } else { - let maxBar = bars.max { $0.cost < $1.cost } - self.peakHour = maxBar?.cost ?? 0 > 0 ? maxBar?.hour : nil - self.peakCost = maxBar?.cost ?? 0 - } - - self.hasError = hasError - self.errorMessage = errorMessage - } - - /// Create an error state chart data - public static func error(_ message: String) -> HourlyChartDataset { - // Create empty bars for all 24 hours - let emptyBars = (0..<24).map { HourlyBar(hour: $0, cost: 0, entryCount: 0) } - return HourlyChartDataset( - bars: emptyBars, - totalCost: 0, - peakHour: nil, - peakCost: 0, - hasError: true, - errorMessage: message - ) - } -} - -/// Tooltip data for hourly chart bars -public struct HourlyTooltip: Equatable { - /// Time range string (e.g., "2:00 PM - 3:00 PM") - public let timeRange: String - - /// Formatted cost string (e.g., "$25.50" or "No usage") - public let cost: String - - /// Entry count string (e.g., "3 requests" or "No requests") - public let entryCount: String - - public init(timeRange: String, cost: String, entryCount: String) { - self.timeRange = timeRange - self.cost = cost - self.entryCount = entryCount - } -} \ No newline at end of file diff --git a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift deleted file mode 100644 index 636e471..0000000 --- a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift +++ /dev/null @@ -1,250 +0,0 @@ -// -// HourlyCostChartSimple.swift -// Simplified Swift Charts implementation for hourly costs -// - -import SwiftUI -import Charts -import ClaudeUsageCore - -// MARK: - Simple Hourly Cost Chart - -struct HourlyCostChartSimple: View { - let hourlyData: [Double] - var maxScale: Double? = nil // Optional shared scale for comparing multiple charts - @State private var selectedHour: Int? = nil - - var body: some View { - GeometryReader { geometry in - let chartWidth = geometry.size.width - 25 - VStack(spacing: 4) { - headerRow - chart - } - .overlay(alignment: .top) { - tooltipOverlay(chartWidth: chartWidth, totalWidth: geometry.size.width) - } - } - .frame(height: 76) // 16 (header) + 60 (chart) - } -} - -// MARK: - Computed Properties - -private extension HourlyCostChartSimple { - var maxValue: Double { - maxScale ?? hourlyData.max() ?? 1.0 - } - - var currentHour: Int { - Calendar.current.component(.hour, from: Date()) - } - - var yAxisScale: YAxisScale { - YAxisScale(maxValue: maxValue) - } - - var headerRow: some View { - Text("Hourly") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - var chart: some View { - costChart - .frame(height: 60) - .padding(.trailing, 25) - } - - var costChart: some View { - Chart(Array(hourlyData.enumerated()), id: \.offset) { hour, cost in - BarMark( - x: .value("Hour", hour), - y: .value("Cost", cost) - ) - .foregroundStyle(CostIntensity(cost: cost, maxValue: maxValue).color) - .opacity(hour <= currentHour ? 1.0 : 0.3) - - if let selectedHour, selectedHour == hour { - selectionIndicator(for: hour) - } - } - .chartXSelection(value: $selectedHour) - .chartXAxis { xAxisMarks } - .chartYAxis { yAxisMarks } - .chartYScale(domain: 0...yAxisScale.roundedMax) - .chartXScale(range: .plotDimension(padding: 8)) - } - - var xAxisMarks: some AxisContent { - AxisMarks(values: [0, 6, 12, 18, 23]) { value in - AxisValueLabel { - if let hour = value.as(Int.self) { - Text("\(hour)") - .font(.system(size: 8)) - .foregroundColor(.secondary) - } - } - } - } - - var yAxisMarks: some AxisContent { - AxisMarks(position: .trailing, values: yAxisScale.tickValues) { value in - AxisValueLabel { - if let cost = value.as(Double.self) { - Text(CostAxisFormat(cost).formatted) - .font(.system(size: 8, design: .monospaced)) - .foregroundColor(.secondary) - } - } - AxisGridLine(stroke: StrokeStyle(lineWidth: 0.25)) - .foregroundStyle(.tertiary) - } - } - - func selectionIndicator(for hour: Int) -> some ChartContent { - RuleMark(x: .value("Hour", hour)) - .foregroundStyle(.primary.opacity(0.3)) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 2])) - } - - @ViewBuilder - func tooltipOverlay(chartWidth: CGFloat, totalWidth: CGFloat) -> some View { - if let selectedHour, - selectedHour >= 0 && selectedHour < hourlyData.count { - HourlyTooltipView( - hour: selectedHour, - cost: hourlyData[selectedHour], - isCompact: true - ) - .offset(x: tooltipXPosition(for: selectedHour, chartWidth: chartWidth, totalWidth: totalWidth)) - .allowsHitTesting(false) - } - } - - func tooltipXPosition(for hour: Int, chartWidth: CGFloat, totalWidth: CGFloat) -> CGFloat { - // With .top alignment, offset is relative to VStack center - let barWidth = chartWidth / 24 - let barCenter = (CGFloat(hour) + 0.5) * barWidth - let vstackCenter = totalWidth / 2 - return barCenter - vstackCenter - } -} - -// MARK: - Preview - -struct HourlyCostChartSimple_Previews: PreviewProvider { - static var previews: some View { - VStack { - HourlyCostChartSimple(hourlyData: sampleHourlyData) - .padding() - Spacer() - } - .frame(width: 300, height: 200) - .background(Color(.windowBackgroundColor)) - } - - static var sampleHourlyData: [Double] { - [ - 0, 0, 0, 2.5, 5.0, 3.2, 8.5, 12.0, 15.5, 10.0, - 8.5, 7.2, 6.0, 9.5, 11.0, 8.0, 5.5, 3.0, 2.0, 1.5, - 0.5, 0, 0, 0 - ] - } -} - -// MARK: - Supporting Types - -// MARK: Cost Intensity - -enum CostIntensity { - case zero - case low - case medium - case high - case peak - - init(cost: Double, maxValue: Double) { - guard cost > 0 else { - self = .zero - return - } - let intensity = min(cost / max(maxValue, 1.0), 1.0) - switch intensity { - case 0.8...: self = .peak - case 0.5...: self = .high - case 0.2...: self = .medium - default: self = .low - } - } - - var color: Color { - switch self { - case .zero: .gray.opacity(0.2) - case .low: .mint - case .medium: .teal - case .high: .cyan - case .peak: .blue - } - } -} - -// MARK: Y-Axis Scale - -enum YAxisScale { - case small(max: Double) - case medium(max: Double) - case large(max: Double) - case extraLarge(max: Double) - - init(maxValue: Double) { - switch maxValue { - case ...10: self = .small(max: maxValue) - case ...50: self = .medium(max: maxValue) - case ...100: self = .large(max: maxValue) - default: self = .extraLarge(max: maxValue) - } - } - - var roundedMax: Double { - switch self { - case .small(let max): ceil(max) - case .medium(let max): ceil(max / 10) * 10 - case .large(let max): ceil(max / 20) * 20 - case .extraLarge(let max): ceil(max / 50) * 50 - } - } - - var tickValues: [Double] { - let max = roundedMax - switch self { - case .small: return [0, max / 2, max] - default: return [0, max / 3, max * 2 / 3, max] - } - } -} - -// MARK: Cost Formatting - -enum CostAxisFormat { - case zero - case decimal(Double) - case whole(Double) - - init(_ value: Double) { - switch value { - case 0: self = .zero - case ..<10: self = .decimal(value) - default: self = .whole(value) - } - } - - var formatted: String { - switch self { - case .zero: "$0" - case .decimal(let value): String(format: "$%.1f", value) - case .whole(let value): String(format: "$%.0f", value) - } - } -} diff --git a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift b/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift deleted file mode 100644 index f372687..0000000 --- a/ClaudeCodeUsage/Shared/Charts/HourlyChart/HourlyTooltipViews.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// HourlyTooltipViews.swift -// Tooltip views for hourly cost charts -// - -import SwiftUI - -// MARK: - Simple Tooltip View - -struct HourlyTooltipView: View { - let hour: Int - let cost: Double - let isCompact: Bool - - private var formattedHour: String { - let formatter = DateFormatter() - formatter.dateFormat = isCompact ? "HH:mm" : "h:mm a" - - let calendar = Calendar.current - let now = Date() - let components = calendar.dateComponents([.year, .month, .day], from: now) - var hourComponents = components - hourComponents.hour = hour - hourComponents.minute = 0 - - if let date = calendar.date(from: hourComponents) { - return formatter.string(from: date) - } - return String(format: "%02d:00", hour) - } - - private var formattedCost: String { - if cost == 0 { - return "$0.00" - } - return cost.asCurrency - } - - var body: some View { - VStack(spacing: 2) { - Text(formattedHour) - .font(.system(size: isCompact ? 9 : 10, weight: .semibold, design: .monospaced)) - .foregroundColor(.primary) - - Text(formattedCost) - .font(.system(size: isCompact ? 8 : 9, weight: .medium, design: .monospaced)) - .foregroundColor(cost > 0 ? .blue : .secondary) - } - .padding(.horizontal, 6) - .padding(.vertical, 4) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(.regularMaterial) - .stroke(.tertiary, lineWidth: 0.5) - .shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 1) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel("Hour \(formattedHour), cost \(formattedCost)") - .transition(.scale(scale: 0.8).combined(with: .opacity)) - .animation(.easeInOut(duration: 0.15), value: hour) - } -} - -// MARK: - Previews - -struct HourlyTooltipView_Previews: PreviewProvider { - static var previews: some View { - VStack(spacing: 20) { - HourlyTooltipView(hour: 14, cost: 12.50, isCompact: false) - HourlyTooltipView(hour: 10, cost: 0, isCompact: true) - } - .padding() - .background(Color(.windowBackgroundColor)) - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift b/ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift deleted file mode 100644 index f1b1f5d..0000000 --- a/ClaudeCodeUsage/Stores/Services/Clock/ClockProtocol.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// ClockProtocol.swift -// Protocol for time abstraction to improve testability -// - -import Foundation - -// MARK: - Clock Protocol - -/// Protocol for abstracting time operations, enabling testable time-dependent code -@MainActor -protocol ClockProtocol: Sendable { - /// Current date and time - var now: Date { get } - - /// Sleep for the specified duration - func sleep(for duration: Duration) async throws - - /// Sleep for the specified time interval (legacy support) - func sleep(for seconds: TimeInterval) async throws -} - -// MARK: - Default Implementations - -extension ClockProtocol { - /// Format date as string - func format(date: Date, format: String) -> String { - DateFormatting.formatted(date, using: format) - } - - /// Calculate time until next occurrence of specified time - func timeUntil(hour: Int, minute: Int, second: Int) -> TimeInterval { - TimeCalculation.intervalUntilNextOccurrence( - of: (hour: hour, minute: minute, second: second), - from: now - ) - } -} - -// MARK: - Clock Provider - -/// Manages clock instance for dependency injection -@MainActor -struct ClockProvider { - private static var _current: ClockProtocol? - - /// Current clock instance (defaults to SystemClock in production) - static var current: ClockProtocol { - get { _current ?? SystemClock() } - set { _current = newValue } - } - - /// Reset to default (SystemClock) - static func reset() { - _current = nil - } - - /// Use test clock for testing - static func useTestClock(_ clock: TestClock) { - _current = clock - } -} - -// MARK: - Pure Date Formatting - -private enum DateFormatting { - static func formatted(_ date: Date, using format: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = format - return formatter.string(from: date) - } -} - -// MARK: - Pure Time Calculation - -private enum TimeCalculation { - static func intervalUntilNextOccurrence( - of time: (hour: Int, minute: Int, second: Int), - from referenceDate: Date - ) -> TimeInterval { - let calendar = Calendar.current - - guard let targetTime = buildTargetTime(time, on: referenceDate, using: calendar) else { - return 0 - } - - return adjustedInterval(from: referenceDate, to: targetTime, using: calendar) - } - - private static func buildTargetTime( - _ time: (hour: Int, minute: Int, second: Int), - on date: Date, - using calendar: Calendar - ) -> Date? { - var components = calendar.dateComponents([.year, .month, .day], from: date) - components.hour = time.hour - components.minute = time.minute - components.second = time.second - return calendar.date(from: components) - } - - private static func adjustedInterval( - from referenceDate: Date, - to targetTime: Date, - using calendar: Calendar - ) -> TimeInterval { - if targetTime <= referenceDate { - return tomorrowInterval(from: referenceDate, to: targetTime, using: calendar) - } - return targetTime.timeIntervalSince(referenceDate) - } - - private static func tomorrowInterval( - from referenceDate: Date, - to targetTime: Date, - using calendar: Calendar - ) -> TimeInterval { - guard let tomorrow = calendar.date(byAdding: .day, value: 1, to: targetTime) else { - return 0 - } - return tomorrow.timeIntervalSince(referenceDate) - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift b/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift deleted file mode 100644 index ad426d2..0000000 --- a/ClaudeCodeUsage/Stores/Services/Clock/SystemClock.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// SystemClock.swift -// Production clock implementation using system time -// - -import Foundation - -// MARK: - System Clock - -/// Real clock implementation using system time -/// Explicitly nonisolated since it's stateless and used in default parameters -nonisolated struct SystemClock: ClockProtocol, Sendable { - var now: Date { - Date() - } - - func sleep(for duration: Duration) async throws { - try await Task.sleep(for: duration) - } - - func sleep(for seconds: TimeInterval) async throws { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift b/ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift deleted file mode 100644 index c99be44..0000000 --- a/ClaudeCodeUsage/Stores/Services/Clock/TestClock.swift +++ /dev/null @@ -1,104 +0,0 @@ -// -// TestClock.swift -// Controllable clock for testing time-dependent code -// - -import Foundation - -// MARK: - Test Clock - -/// Controllable clock for testing time-dependent code -@MainActor -final class TestClock: ClockProtocol { - private(set) var currentTime: Date - private var sleepRecords: [(duration: TimeInterval, timestamp: Date)] = [] - - init(startTime: Date = Date()) { - self.currentTime = startTime - } - - var now: Date { - currentTime - } - - func sleep(for duration: Duration) async throws { - try await sleep(for: duration.asTimeInterval) - } - - func sleep(for seconds: TimeInterval) async throws { - sleepRecords.append((duration: seconds, timestamp: currentTime)) - advance(by: seconds) - await Task.yield() - } - - // MARK: - Test Control Methods - - /// Advance time by the specified interval - func advance(by interval: TimeInterval) { - currentTime = currentTime.addingTimeInterval(interval) - } - - /// Set the current time to a specific date - func setTime(to date: Date) { - currentTime = date - } - - /// Advance to just before midnight - func advanceToAlmostMidnight() { - if let almostMidnight = TimeBuilder.almostMidnight(on: currentTime) { - currentTime = almostMidnight - } - } - - /// Advance to the next day - func advanceToNextDay() { - if let nextDayStart = TimeBuilder.startOfNextDay(after: currentTime) { - currentTime = nextDayStart - } - } - - /// Get all sleep records for verification - var sleepHistory: [(duration: TimeInterval, timestamp: Date)] { - sleepRecords - } - - /// Clear sleep history - func clearHistory() { - sleepRecords.removeAll() - } -} - -// MARK: - Duration Extension - -private extension Duration { - var asTimeInterval: TimeInterval { - let seconds = components.seconds - let attoseconds = components.attoseconds - return Double(seconds) + Double(attoseconds) / 1_000_000_000_000_000_000 - } -} - -// MARK: - Time Builder - -private enum TimeBuilder { - static func almostMidnight(on date: Date) -> Date? { - let calendar = Calendar.current - var components = calendar.dateComponents([.year, .month, .day], from: date) - components.hour = 23 - components.minute = 59 - components.second = 59 - return calendar.date(from: components) - } - - static func startOfNextDay(after date: Date) -> Date? { - let calendar = Calendar.current - guard let nextDay = calendar.date(byAdding: .day, value: 1, to: date) else { - return nil - } - var components = calendar.dateComponents([.year, .month, .day], from: nextDay) - components.hour = 0 - components.minute = 0 - components.second = 1 - return calendar.date(from: components) - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift b/ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift deleted file mode 100644 index 17c3d50..0000000 --- a/ClaudeCodeUsage/Stores/Services/Loading/AppConfiguration.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppConfiguration.swift -// App-wide configuration and configuration service -// - -import Foundation - -// MARK: - Home Directory Helper - -/// Returns the real user home directory, even in sandboxed apps. -/// In sandboxed apps, NSHomeDirectory() returns the container path, -/// but we need the actual user home to access ~/.claude -private func realHomeDirectory() -> String { - guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } - return String(cString: pw.pointee.pw_dir) -} - -// MARK: - Configuration - -public struct AppConfiguration: Sendable { - let basePath: String - let refreshInterval: TimeInterval - let sessionDurationHours: Double - let dailyCostThreshold: Double - - static let `default` = AppConfiguration( - basePath: realHomeDirectory() + "/.claude", - refreshInterval: 30.0, - sessionDurationHours: 5.0, - dailyCostThreshold: 10.0 - ) - - static func load() -> AppConfiguration { - // Future: Load from UserDefaults or config file - return .default - } -} - -// MARK: - Configuration Service - -protocol ConfigurationService { - var configuration: AppConfiguration { get } -} - -final class DefaultConfigurationService: ConfigurationService { - let configuration: AppConfiguration - - init() { - self.configuration = AppConfiguration.load() - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift b/ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift deleted file mode 100644 index 05ac0d9..0000000 --- a/ClaudeCodeUsage/Stores/Services/Loading/LoadTrace.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// LoadTrace.swift -// Event-driven load operation tracing -// - -import Foundation -import OSLog - -// MARK: - Trace Collector - -actor LoadTrace { - static let shared = LoadTrace() - - private let logger = Logger(subsystem: "com.claudecodeusage", category: "DataFlow") - - private var loadStartTime: Date? - private var phaseStartTimes: [LoadPhase: Date] = [:] - private var phaseDurations: [LoadPhase: TimeInterval] = [:] - - // Session monitor state - private var sessionFound: Bool? - private var sessionCached: Bool = false - private var sessionDuration: TimeInterval = 0 - private var tokenLimit: Int? - private var historySkipped: Bool = false - - func start() -> UUID { - let id = UUID() - loadStartTime = Date() - resetState() - return id - } - - func phaseStart(_ phase: LoadPhase) { - phaseStartTimes[phase] = Date() - } - - func phaseComplete(_ phase: LoadPhase) { - if let start = phaseStartTimes[phase] { - phaseDurations[phase] = Date().timeIntervalSince(start) - } - } - - func recordSession(found: Bool, cached: Bool, duration: TimeInterval, tokenLimit: Int?) { - sessionFound = found - sessionCached = cached - sessionDuration = duration - self.tokenLimit = tokenLimit - } - - func skipHistory() { - historySkipped = true - } - - func complete() { - guard let startTime = loadStartTime else { return } - let duration = Date().timeIntervalSince(startTime) - printSummary(duration: duration) - resetState() - } - - // MARK: - State Management - - private func resetState() { - phaseStartTimes = [:] - phaseDurations = [:] - sessionFound = nil - sessionCached = false - sessionDuration = 0 - tokenLimit = nil - historySkipped = false - } - - // MARK: - Output - - private func printSummary(duration: TimeInterval) { - let output = buildSummary(duration: duration) - logOutput(output, isSlow: duration > Threshold.slowLoad) - } - - private func buildSummary(duration: TimeInterval) -> String { - [ - headerLine, - todayPhaseLine, - sessionLine, - historyPhaseLine, - footerLine(duration: duration) - ] - .compactMap { $0 } - .joined(separator: "\n") - } - - private var headerLine: String { - "┌─ Data Load " + String(repeating: "─", count: 40) - } - - private var todayPhaseLine: String { - let duration = phaseDurations[.today].map { " (\(formatDuration($0)))" } ?? "" - return "│ Phase 1: Today\(duration)" - } - - private var sessionLine: String? { - sessionFound.map { found in - let status = found ? "active" : "none" - let timing = sessionCached ? "cached" : formatDuration(sessionDuration) - let limitInfo = tokenLimit.map { ", limit: \(formatNumber($0))" } ?? "" - return "│ Session: \(status) [\(timing)]\(limitInfo)" - } - } - - private var historyPhaseLine: String { - if historySkipped { - return "│ Phase 2: History (skipped - same day)" - } - let duration = phaseDurations[.history].map { " (\(formatDuration($0)))" } ?? "" - return "│ Phase 2: History\(duration)" - } - - private func footerLine(duration: TimeInterval) -> String { - let status = duration > Threshold.slowLoad ? " [slow]" : "" - return "└─ Total: \(formatDuration(duration))\(status) " + String(repeating: "─", count: 28) - } - - private func logOutput(_ output: String, isSlow: Bool) { - if isSlow { - logger.warning("\(output)") - } else { - logger.info("\(output)") - } - } - - // MARK: - Formatting - - private func formatDuration(_ seconds: TimeInterval) -> String { - DurationFormatter.format(seconds) - } - - private func formatNumber(_ n: Int) -> String { - NumberFormat.decimal(n) - } -} - -// MARK: - Supporting Types - -enum LoadPhase: String { - case today = "Today" - case history = "History" -} - -private enum Threshold { - static let slowLoad: TimeInterval = 2.0 -} - -// MARK: - Pure Formatters - -private enum DurationFormatter { - static func format(_ seconds: TimeInterval) -> String { - switch seconds { - case ..<0.01: "<10ms" - case ..<1.0: String(format: "%.0fms", seconds * 1000) - default: String(format: "%.2fs", seconds) - } - } -} - -private enum NumberFormat { - private static let decimalFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - }() - - static func decimal(_ n: Int) -> String { - decimalFormatter.string(from: NSNumber(value: n)) ?? "\(n)" - } -} diff --git a/ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift b/ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift deleted file mode 100644 index 1f965e3..0000000 --- a/ClaudeCodeUsage/Stores/Services/Loading/SessionMonitorService.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// SessionMonitorService.swift -// Service for monitoring active Claude sessions -// - -import Foundation -import ClaudeUsageCore -import ClaudeUsageData - -// MARK: - Protocol - -protocol SessionMonitorService: Sendable { - func getActiveSession() async -> SessionBlock? - func getBurnRate() async -> BurnRate? - func getAutoTokenLimit() async -> Int? -} - -// MARK: - Default Implementation - -actor DefaultSessionMonitorService: SessionMonitorService { - private let monitor: SessionMonitor - private var cachedSession: (session: SessionBlock?, timestamp: Date)? - - init(configuration: AppConfiguration) { - self.monitor = SessionMonitor( - basePath: configuration.basePath, - sessionDurationHours: configuration.sessionDurationHours - ) - } - - func getActiveSession() async -> SessionBlock? { - if let cached = cachedSession, isCacheValid(timestamp: cached.timestamp) { - return cached.session - } - - let session = await monitor.getActiveSession() - cachedSession = (session, Date()) - return session - } - - func getBurnRate() async -> BurnRate? { - await getActiveSession()?.burnRate - } - - func getAutoTokenLimit() async -> Int? { - // Derive from session to avoid redundant monitor call - // (SessionBlock.tokenLimit is populated by getActiveSession) - await getActiveSession()?.tokenLimit - } -} - -// MARK: - Supporting Types - -private enum CacheConfig { - static let ttl: TimeInterval = 2.0 -} - -// MARK: - Pure Functions - -private func isCacheValid(timestamp: Date, ttl: TimeInterval = CacheConfig.ttl) -> Bool { - Date().timeIntervalSince(timestamp) < ttl -} - -// MARK: - Mock for Testing - -#if DEBUG -final class MockSessionMonitorService: SessionMonitorService, @unchecked Sendable { - var mockSession: SessionBlock? - var mockBurnRate: BurnRate? - var mockTokenLimit: Int? - - func getActiveSession() async -> SessionBlock? { mockSession } - func getBurnRate() async -> BurnRate? { mockBurnRate } - func getAutoTokenLimit() async -> Int? { mockTokenLimit } -} -#endif diff --git a/ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift b/ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift deleted file mode 100644 index 2810e10..0000000 --- a/ClaudeCodeUsage/Stores/Services/Loading/UsageDataLoader.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// UsageDataLoader.swift -// Orchestrates data loading from repository and session services -// - -import Foundation -import ClaudeUsageCore - -// MARK: - UsageDataLoader - -actor UsageDataLoader { - private let repository: any UsageDataSource - private let sessionMonitorService: SessionMonitorService - - init(repository: any UsageDataSource, sessionMonitorService: SessionMonitorService) { - self.repository = repository - self.sessionMonitorService = sessionMonitorService - } - - func loadToday() async throws -> TodayLoadResult { - try await tracePhase(.today) { - try await fetchTodayData() - } - } - - func loadHistory() async throws -> FullLoadResult { - try await tracePhase(.history) { - FullLoadResult(fullStats: try await repository.getUsageStats()) - } - } - - func loadAll() async throws -> UsageLoadResult { - let today = try await loadToday() - let history = try await loadHistory() - return combineResults(today: today, history: history) - } -} - -// MARK: - Data Fetching - -private extension UsageDataLoader { - func fetchTodayData() async throws -> TodayLoadResult { - async let entriesTask = repository.getTodayEntries() - async let sessionTask = fetchSessionWithTracing() - - let entries = try await entriesTask - let (session, burnRate) = await sessionTask - - return buildTodayResult(entries: entries, session: session, burnRate: burnRate) - } - - func fetchSessionWithTracing() async -> (SessionBlock?, BurnRate?) { - let (session, timing) = await timed { await sessionMonitorService.getActiveSession() } - await recordSessionTrace(session: session, timing: timing) - return (session, session?.burnRate) - } -} - -// MARK: - Result Building - -private extension UsageDataLoader { - func buildTodayResult( - entries: [UsageEntry], - session: SessionBlock?, - burnRate: BurnRate? - ) -> TodayLoadResult { - TodayLoadResult( - todayEntries: entries, - todayStats: UsageAggregator.aggregate(entries), - session: session, - burnRate: burnRate, - autoTokenLimit: session?.tokenLimit - ) - } - - func combineResults(today: TodayLoadResult, history: FullLoadResult) -> UsageLoadResult { - UsageLoadResult( - todayEntries: today.todayEntries, - todayStats: today.todayStats, - fullStats: history.fullStats, - session: today.session, - burnRate: today.burnRate, - autoTokenLimit: today.autoTokenLimit - ) - } -} - -// MARK: - Tracing Infrastructure - -private extension UsageDataLoader { - private enum TracingThreshold { - static let cachedResponseTime: TimeInterval = 0.05 - } - - func tracePhase(_ phase: LoadPhase, operation: () async throws -> T) async rethrows -> T { - await LoadTrace.shared.phaseStart(phase) - let result = try await operation() - await LoadTrace.shared.phaseComplete(phase) - return result - } - - func recordSessionTrace(session: SessionBlock?, timing: TimeInterval) async { - await LoadTrace.shared.recordSession( - found: session != nil, - cached: timing < TracingThreshold.cachedResponseTime, - duration: timing, - tokenLimit: session?.tokenLimit - ) - } - - func timed(_ operation: () async -> T) async -> (T, TimeInterval) { - let start = Date() - let result = await operation() - return (result, Date().timeIntervalSince(start)) - } -} - -// MARK: - Supporting Types - -/// Fast result from Phase 1 - today's data only -struct TodayLoadResult { - let todayEntries: [UsageEntry] - let todayStats: UsageStats - let session: SessionBlock? - let burnRate: BurnRate? - let autoTokenLimit: Int? -} - -/// Complete result including historical data -struct FullLoadResult { - let fullStats: UsageStats -} - -/// Combined result for backward compatibility -struct UsageLoadResult { - let todayEntries: [UsageEntry] - let todayStats: UsageStats - let fullStats: UsageStats - let session: SessionBlock? - let burnRate: BurnRate? - let autoTokenLimit: Int? -} diff --git a/ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift b/ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift deleted file mode 100644 index 075130c..0000000 --- a/ClaudeCodeUsage/Stores/Services/RefreshCoordinator.swift +++ /dev/null @@ -1,193 +0,0 @@ -// -// RefreshCoordinator.swift -// Manages refresh via file monitoring, lifecycle events, and day change detection -// - -import Foundation -import AppKit -import ClaudeUsageData - -// MARK: - Home Directory Helper - -private func realHomeDirectory() -> String { - guard let pw = getpwuid(getuid()) else { return NSHomeDirectory() } - return String(cString: pw.pointee.pw_dir) -} - -// MARK: - Timing Constants - -private enum Timing { - static let fallbackInterval: TimeInterval = 300.0 - static let debounceInterval: TimeInterval = 1.0 - static let refreshThreshold: TimeInterval = 2.0 -} - -// MARK: - Refresh Coordinator - -@MainActor -final class RefreshCoordinator { - private var fallbackTimerTask: Task? - private var dayChangeObserver: NSObjectProtocol? - private var lastKnownDay: String - private var lastRefreshTime: Date - private let clock: any ClockProtocol - private let monitoredPath: String - private let directoryMonitor: DirectoryMonitor - - var onRefresh: (() async -> Void)? - - private static let dayFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd" - return formatter - }() - - // MARK: - Initialization - - init( - clock: any ClockProtocol = SystemClock(), - refreshInterval: TimeInterval, - basePath: String = realHomeDirectory() + "/.claude" - ) { - self.clock = clock - self.lastRefreshTime = clock.now - self.lastKnownDay = Self.formatDay(clock.now) - self.monitoredPath = basePath + "/projects" - self.directoryMonitor = DirectoryMonitor(path: monitoredPath, debounceInterval: Timing.debounceInterval) - - setupDirectoryMonitor() - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: - Public API - - func start() { - stop() - directoryMonitor.start() - startFallbackTimer() - startDayChangeMonitoring() - } - - func stop() { - directoryMonitor.stop() - stopFallbackTimer() - stopDayChangeMonitoring() - } - - func handleAppBecameActive() { - triggerRefreshIfNeeded() - start() - } - - func handleAppResignActive() { - stop() - } - - func handleWindowFocus() { - triggerRefreshIfNeeded() - } - - // MARK: - Refresh Logic - - private func triggerRefreshIfNeeded() { - guard shouldRefresh() else { return } - triggerRefresh() - } - - private func triggerRefresh() { - lastRefreshTime = clock.now - Task { await onRefresh?() } - } - - private func shouldRefresh() -> Bool { - clock.now.timeIntervalSince(lastRefreshTime) > Timing.refreshThreshold - } - - // MARK: - Directory Monitoring - - private func setupDirectoryMonitor() { - directoryMonitor.onChange = { [weak self] in - Task { @MainActor [weak self] in - self?.triggerRefreshIfNeeded() - } - } - } - - // MARK: - Fallback Timer - - private func startFallbackTimer() { - fallbackTimerTask = Task { @MainActor in - while !Task.isCancelled { - do { - try await Task.sleep(for: .seconds(Timing.fallbackInterval)) - guard !Task.isCancelled else { break } - triggerRefresh() - } catch { - break - } - } - } - } - - private func stopFallbackTimer() { - fallbackTimerTask?.cancel() - fallbackTimerTask = nil - } - - // MARK: - Day Change Monitoring - - private func startDayChangeMonitoring() { - stopDayChangeMonitoring() - observeCalendarDayChange() - observeSystemClockChange() - } - - private func stopDayChangeMonitoring() { - dayChangeObserver.map { NotificationCenter.default.removeObserver($0) } - dayChangeObserver = nil - } - - private func observeCalendarDayChange() { - dayChangeObserver = NotificationCenter.default.addObserver( - forName: .NSCalendarDayChanged, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.handleDayChange() - } - } - } - - private func observeSystemClockChange() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleSignificantTimeChange), - name: NSNotification.Name.NSSystemClockDidChange, - object: nil - ) - } - - private func handleDayChange() { - lastKnownDay = Self.formatDay(clock.now) - triggerRefresh() - } - - @objc private func handleSignificantTimeChange() { - Task { @MainActor in - let currentDay = Self.formatDay(clock.now) - guard currentDay != lastKnownDay else { return } - lastKnownDay = currentDay - triggerRefresh() - } - } - - // MARK: - Pure Functions - - private static func formatDay(_ date: Date) -> String { - dayFormatter.string(from: date) - } -} diff --git a/ClaudeCodeUsage/Stores/UsageStore.swift b/ClaudeCodeUsage/Stores/UsageStore.swift deleted file mode 100644 index d5f9544..0000000 --- a/ClaudeCodeUsage/Stores/UsageStore.swift +++ /dev/null @@ -1,256 +0,0 @@ -// -// UsageStore.swift -// Observable state container for usage data -// - -import SwiftUI -import Observation -import ClaudeUsageCore -import ClaudeUsageData - -// MARK: - Usage Store - -@Observable -@MainActor -public final class UsageStore { - // MARK: - Source State - - private(set) var state: ViewState = .loading - private(set) var activeSession: SessionBlock? - private(set) var burnRate: BurnRate? - private(set) var todayEntries: [UsageEntry] = [] - - // MARK: - Configuration - - private let defaultThreshold: Double - - // MARK: - Derived Properties - - var isLoading: Bool { state.isLoading } - var stats: UsageStats? { state.stats } - - var todaysCost: Double { - todayEntries.reduce(0.0) { $0 + $1.costUSD } - } - - var dailyCostThreshold: Double { - deriveThreshold(from: stats, default: defaultThreshold) - } - - var todaysCostProgress: Double { - progressCapped(todaysCost / dailyCostThreshold) - } - - var sessionTimeProgress: Double { - activeSession.map { sessionProgress($0, now: clock.now) } ?? 0 - } - - var averageDailyCost: Double { - stats.flatMap { recentDaysAverage($0.byDate) } ?? 0 - } - - var todayHourlyCosts: [Double] { - UsageAggregator.todayHourlyCosts(from: todayEntries, referenceDate: clock.now) - } - - var formattedTodaysCost: String { - todaysCost.asCurrency - } - - // MARK: - Dependencies - - private let dataLoader: UsageDataLoader - private let clock: any ClockProtocol - private let refreshCoordinator: RefreshCoordinator - - // MARK: - Internal State - - private var isCurrentlyLoading = false - private var lastLoadStartTime: Date? - private var hasInitialized = false - private var lastHistoryLoadDate: Date? - - // MARK: - Initialization - - public convenience init() { - self.init(repository: nil, sessionMonitorService: nil, configurationService: nil, clock: SystemClock()) - } - - init( - repository: (any UsageDataSource)? = nil, - sessionMonitorService: SessionMonitorService? = nil, - configurationService: ConfigurationService? = nil, - clock: any ClockProtocol = SystemClock() - ) { - let config = configurationService ?? DefaultConfigurationService() - let repo = repository ?? UsageRepository(basePath: config.configuration.basePath) - let sessionService = sessionMonitorService ?? DefaultSessionMonitorService(configuration: config.configuration) - - self.dataLoader = UsageDataLoader(repository: repo, sessionMonitorService: sessionService) - self.clock = clock - self.defaultThreshold = config.configuration.dailyCostThreshold - self.refreshCoordinator = RefreshCoordinator( - clock: clock, - refreshInterval: config.configuration.refreshInterval, - basePath: config.configuration.basePath - ) - - refreshCoordinator.onRefresh = { [weak self] in - await self?.loadData() - } - } - - // MARK: - Public API - - func initializeIfNeeded() async { - guard !hasInitialized else { return } - hasInitialized = true - - if !state.hasLoaded { - await loadData() - } - refreshCoordinator.start() - } - - func loadData() async { - guard canStartLoad else { return } - await trackLoadExecution { try await executeLoad() } - } - - // MARK: - Load Execution - - private var canStartLoad: Bool { - !isCurrentlyLoading && !isLoadedRecently - } - - private func trackLoadExecution(_ load: () async throws -> Void) async { - isCurrentlyLoading = true - lastLoadStartTime = clock.now - _ = await LoadTrace.shared.start() - defer { isCurrentlyLoading = false } - - do { - try await load() - await LoadTrace.shared.complete() - } catch { - state = .error(error) - } - } - - private func executeLoad() async throws { - let todayResult = try await dataLoader.loadToday() - apply(todayResult) - try await loadHistoryIfNeeded() - } - - private func loadHistoryIfNeeded() async throws { - guard shouldLoadHistory else { - await LoadTrace.shared.skipHistory() - return - } - let historyResult = try await dataLoader.loadHistory() - apply(historyResult) - lastHistoryLoadDate = clock.now - } - - private var shouldLoadHistory: Bool { - guard let lastDate = lastHistoryLoadDate else { return true } - return !Calendar.current.isDate(lastDate, inSameDayAs: clock.now) - } - - // MARK: - State Transitions - - private func apply(_ result: TodayLoadResult) { - activeSession = result.session - burnRate = result.burnRate - todayEntries = result.todayEntries - - if case .loaded = state { return } - state = .loadedToday(result.todayStats) - } - - private func apply(_ result: FullLoadResult) { - state = .loaded(result.fullStats) - } - - func refresh() async { - await loadData() - } - - // MARK: - Lifecycle - - func handleAppBecameActive() { - refreshCoordinator.handleAppBecameActive() - } - - func handleAppResignActive() { - refreshCoordinator.handleAppResignActive() - } - - func handleWindowFocus() { - refreshCoordinator.handleWindowFocus() - } - - func stopRefreshTimer() { - refreshCoordinator.stop() - } - - // MARK: - Helpers - - private var isLoadedRecently: Bool { - guard let lastTime = lastLoadStartTime else { return false } - return clock.now.timeIntervalSince(lastTime) < 2.0 - } -} - -// MARK: - Supporting Types - -enum ViewState { - case loading - case loadedToday(UsageStats) - case loaded(UsageStats) - case error(Error) - - var isLoading: Bool { - if case .loading = self { return true } - return false - } - - var hasLoaded: Bool { - switch self { - case .loadedToday, .loaded: return true - default: return false - } - } - - var stats: UsageStats? { - if case .loaded(let stats) = self { return stats } - return nil - } -} - -// MARK: - Pure Functions - -private func deriveThreshold(from stats: UsageStats?, default defaultThreshold: Double) -> Double { - guard let stats = stats, !stats.byDate.isEmpty else { return defaultThreshold } - let average = recentDaysAverage(stats.byDate) ?? 0 - return average > 0 ? max(average * 1.5, 10.0) : defaultThreshold -} - -private func recentDaysAverage(_ byDate: [DailyUsage]) -> Double? { - guard !byDate.isEmpty else { return nil } - let recentDays = byDate.suffix(7) - return recentDays.reduce(0.0) { $0 + $1.totalCost } / Double(recentDays.count) -} - -private func progressCapped(_ value: Double) -> Double { - min(value, 1.5) -} - -private func sessionProgress(_ session: SessionBlock, now: Date) -> Double { - // Progress = time since session started / 5h window - let elapsed = now.timeIntervalSince(session.startTime) - let total = session.endTime.timeIntervalSince(session.startTime) - guard total > 0 else { return 0 } - return min(elapsed / total, 1.0) -} diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift index 0ea065a..0922338 100644 --- a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift +++ b/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift @@ -6,6 +6,10 @@ import SwiftUI import ClaudeUsageCore +// MARK: - Preview Detection + +private let isRunningForPreviews = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PLAYGROUNDS"] == "1" + // MARK: - Menu Bar Scene public struct MenuBarScene: Scene { @@ -29,16 +33,29 @@ public struct MenuBarScene: Scene { .menuBarExtraStyle(.window) } + @ViewBuilder private var menuContent: some View { - MenuBarContentView(settingsService: settingsService) - .environment(store) + if isRunningForPreviews { + // Minimal content for preview mode to avoid blocking app launch + Text("Preview Mode") + .frame(width: 200, height: 100) + } else { + MenuBarContentView(settingsService: settingsService) + .environment(store) + } } + @ViewBuilder private var menuLabel: some View { - MenuBarLabel(store: store) - .environment(store) - .task { await initializeOnce() } - .contextMenu { contextMenu } + if isRunningForPreviews { + // Minimal label for preview mode + Image(systemName: "dollarsign.circle") + } else { + MenuBarLabel(store: store) + .environment(store) + .task { await initializeOnce() } + .contextMenu { contextMenu } + } } private var contextMenu: some View { From c9a2cce8937e7eaa69707ff37b5f723d1a88c0db Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:05:19 +0100 Subject: [PATCH 3/9] refactor(ui): remove duplicate executable from ClaudeUsageUI package - Remove ClaudeCodeUsage executable target from package - Keep ClaudeUsageUI as pure library - Xcode project provides the single app entry point --- Packages/ClaudeUsageUI/Package.swift | 7 -- .../App/ClaudeCodeUsageApp.swift | 87 ------------------- 2 files changed, 94 deletions(-) delete mode 100644 Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift diff --git a/Packages/ClaudeUsageUI/Package.swift b/Packages/ClaudeUsageUI/Package.swift index c648d9b..c0ef298 100644 --- a/Packages/ClaudeUsageUI/Package.swift +++ b/Packages/ClaudeUsageUI/Package.swift @@ -11,9 +11,6 @@ let package = Package( .library( name: "ClaudeUsageUI", targets: ["ClaudeUsageUI"]), - .executable( - name: "ClaudeCodeUsage", - targets: ["ClaudeCodeUsage"]), ], dependencies: [ .package(path: "../ClaudeUsageCore"), @@ -27,10 +24,6 @@ let package = Package( "ClaudeUsageData", ]), - .executableTarget( - name: "ClaudeCodeUsage", - dependencies: ["ClaudeUsageUI"]), - .testTarget( name: "ClaudeUsageUITests", dependencies: [ diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift b/Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift deleted file mode 100644 index 963b9e6..0000000 --- a/Packages/ClaudeUsageUI/Sources/ClaudeCodeUsage/App/ClaudeCodeUsageApp.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ClaudeCodeUsageApp.swift -// App entry point -// - -import SwiftUI -import ClaudeUsageCore -import ClaudeUsageUI - -// MARK: - App Entry Point - -@main -struct ClaudeCodeUsageApp: App { - @State private var store = UsageStore() - @State private var lifecycleManager = AppLifecycleManager() - @State private var settingsService = AppSettingsService() - - var body: some Scene { - mainWindow - menuBarScene - } - - private var mainWindow: some Scene { - Window(AppMetadata.name, id: "main") { - MainView(settingsService: settingsService) - .environment(store) - } - .defaultLaunchBehavior(.suppressed) - .windowStyle(.automatic) - .windowToolbarStyle(.unified) - .defaultSize(width: 840, height: 600) - .commands { AppCommands(settingsService: settingsService) } - } - - private var menuBarScene: some Scene { - MenuBarScene(store: store, settingsService: settingsService, lifecycleManager: lifecycleManager) - } -} - -// MARK: - App Commands - -struct AppCommands: Commands { - let settingsService: AppSettingsService - - var body: some Commands { - aboutCommand - settingsCommand - viewMenu - } - - private var aboutCommand: some Commands { - CommandGroup(replacing: .appInfo) { - Button("About \(AppMetadata.name)") { - settingsService.showAboutPanel() - } - } - } - - private var settingsCommand: some Commands { - CommandGroup(after: .appSettings) { - OpenAtLoginToggle(settingsService: settingsService) - } - } - - private var viewMenu: some Commands { - CommandMenu("View") { - refreshButton - Divider() - showWindowButton - } - } - - private var refreshButton: some View { - Button("Refresh") { - NotificationCenter.default.post(name: .refreshData, object: nil) - } - .keyboardShortcut("R", modifiers: .command) - } - - private var showWindowButton: some View { - Button("Show Main Window") { - WindowActions.showMainWindow() - } - .keyboardShortcut("1", modifiers: .command) - } -} - From 00c84eff69a17aca4837f9855a9e6754f43243b4 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:08:05 +0100 Subject: [PATCH 4/9] refactor(ui): flatten Sources directory structure - Move files from Sources/ClaudeUsageUI/ to Sources/ - Add explicit path parameter to target definition - Reduce nesting depth for cleaner navigation --- Packages/ClaudeUsageUI/Package.swift | 3 ++- .../Sources/{ClaudeUsageUI => }/AppLifecycleManager.swift | 0 .../MainWindow/Analytics/AnalyticsRows.swift | 0 .../MainWindow/Analytics/AnalyticsView.swift | 0 .../MainWindow/Analytics/Cards/AnalyticsCard.swift | 0 .../MainWindow/Analytics/Cards/PredictionsCard.swift | 0 .../MainWindow/Analytics/Cards/TokenDistributionCard.swift | 0 .../MainWindow/Analytics/Cards/UsageTrendsCard.swift | 0 .../MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift | 0 .../Configuration/HeatmapConfiguration+Accessibility.swift | 0 .../Configuration/HeatmapConfiguration+ColorThemes.swift | 0 .../Analytics/Heatmap/Configuration/HeatmapConfiguration.swift | 0 .../MainWindow/Analytics/Heatmap/Grid/DaySquare.swift | 0 .../MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift | 0 .../MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift | 0 .../Analytics/Heatmap/Grid/HeatmapGridPerformance.swift | 0 .../MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift | 0 .../Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift | 0 .../MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift | 0 .../Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift | 0 .../MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift | 0 .../MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift | 0 .../Analytics/Heatmap/Models/ActivityLevelLabels.swift | 0 .../MainWindow/Analytics/Heatmap/Models/BorderStyle.swift | 0 .../MainWindow/Analytics/Heatmap/Models/ColorScheme.swift | 0 .../MainWindow/Analytics/Heatmap/Models/DateConstants.swift | 0 .../Analytics/Heatmap/Models/DateRangeValidation.swift | 0 .../MainWindow/Analytics/Heatmap/Models/HeatmapData.swift | 0 .../Analytics/Heatmap/Models/HeatmapDateCalculator.swift | 0 .../MainWindow/Analytics/Heatmap/Models/MonthOperations.swift | 0 .../MainWindow/Analytics/Heatmap/Models/WeekOperations.swift | 0 .../Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift | 0 .../Heatmap/Stores/HeatmapStore+SupportingTypes.swift | 0 .../MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift | 0 .../Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift | 0 .../MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift | 0 .../Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift | 0 .../Analytics/Heatmap/Tooltip/TooltipConfiguration.swift | 0 .../Analytics/Heatmap/Tooltip/TooltipPositioning.swift | 0 .../Analytics/Heatmap/YearlyCostHeatmap+Factories.swift | 0 .../Analytics/Heatmap/YearlyCostHeatmap+Preview.swift | 0 .../MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift | 0 .../MainWindow/Components/EmptyStateView.swift | 0 .../{ClaudeUsageUI => }/MainWindow/Daily/DailyUsageView.swift | 0 .../Sources/{ClaudeUsageUI => }/MainWindow/MainView.swift | 0 .../{ClaudeUsageUI => }/MainWindow/Models/ModelsView.swift | 0 .../{ClaudeUsageUI => }/MainWindow/Overview/MetricCard.swift | 0 .../{ClaudeUsageUI => }/MainWindow/Overview/OverviewView.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/ActionButtons.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/GraphView.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/MetricRow.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/ProgressBar.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/SectionHeader.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Components/SettingsMenu.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Helpers/ColorService.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Helpers/FormatterService.swift | 0 .../{ClaudeUsageUI => }/MenuBar/MenuBarContentView.swift | 0 .../Sources/{ClaudeUsageUI => }/MenuBar/MenuBarScene.swift | 0 .../MenuBar/Sections/CostMetricsSection.swift | 0 .../MenuBar/Sections/SessionMetricsSection.swift | 0 .../MenuBar/Sections/UsageMetricsSection.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Theme/MenuBarStyles.swift | 0 .../{ClaudeUsageUI => }/MenuBar/Theme/MenuBarTheme.swift | 0 .../{ClaudeUsageUI => }/Settings/AppSettingsService.swift | 0 .../{ClaudeUsageUI => }/Settings/OpenAtLoginToggle.swift | 0 .../Shared/Charts/HourlyChart/HourlyChartModels.swift | 0 .../Shared/Charts/HourlyChart/HourlyCostChartSimple.swift | 0 .../Shared/Charts/HourlyChart/HourlyTooltipViews.swift | 0 .../Stores/Services/Clock/ClockProtocol.swift | 0 .../Stores/Services/Clock/SystemClock.swift | 0 .../{ClaudeUsageUI => }/Stores/Services/Clock/TestClock.swift | 0 .../Stores/Services/Loading/AppConfiguration.swift | 0 .../Stores/Services/Loading/LoadTrace.swift | 0 .../Stores/Services/Loading/SessionMonitorService.swift | 0 .../Stores/Services/Loading/UsageDataLoader.swift | 0 .../Stores/Services/RefreshCoordinator.swift | 0 .../Sources/{ClaudeUsageUI => }/Stores/UsageStore.swift | 0 77 files changed, 2 insertions(+), 1 deletion(-) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/AppLifecycleManager.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/AnalyticsRows.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/AnalyticsView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Cards/AnalyticsCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Cards/PredictionsCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Cards/TokenDistributionCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Cards/UsageTrendsCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/DateConstants.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Components/EmptyStateView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Daily/DailyUsageView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/MainView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Models/ModelsView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Overview/MetricCard.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MainWindow/Overview/OverviewView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/ActionButtons.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/GraphView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/MetricRow.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/ProgressBar.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/SectionHeader.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Components/SettingsMenu.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Helpers/ColorService.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Helpers/FormatterService.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/MenuBarContentView.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/MenuBarScene.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Sections/CostMetricsSection.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Sections/SessionMetricsSection.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Sections/UsageMetricsSection.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Theme/MenuBarStyles.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/MenuBar/Theme/MenuBarTheme.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Settings/AppSettingsService.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Settings/OpenAtLoginToggle.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Shared/Charts/HourlyChart/HourlyChartModels.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Shared/Charts/HourlyChart/HourlyTooltipViews.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Clock/ClockProtocol.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Clock/SystemClock.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Clock/TestClock.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Loading/AppConfiguration.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Loading/LoadTrace.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Loading/SessionMonitorService.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/Loading/UsageDataLoader.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/Services/RefreshCoordinator.swift (100%) rename Packages/ClaudeUsageUI/Sources/{ClaudeUsageUI => }/Stores/UsageStore.swift (100%) diff --git a/Packages/ClaudeUsageUI/Package.swift b/Packages/ClaudeUsageUI/Package.swift index c0ef298..b26b9a7 100644 --- a/Packages/ClaudeUsageUI/Package.swift +++ b/Packages/ClaudeUsageUI/Package.swift @@ -22,7 +22,8 @@ let package = Package( dependencies: [ "ClaudeUsageCore", "ClaudeUsageData", - ]), + ], + path: "Sources"), .testTarget( name: "ClaudeUsageUITests", diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift b/Packages/ClaudeUsageUI/Sources/AppLifecycleManager.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/AppLifecycleManager.swift rename to Packages/ClaudeUsageUI/Sources/AppLifecycleManager.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/AnalyticsRows.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsRows.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/AnalyticsRows.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/AnalyticsView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/AnalyticsView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/AnalyticsView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/AnalyticsCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/AnalyticsCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/AnalyticsCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/PredictionsCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/PredictionsCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/PredictionsCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/TokenDistributionCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/TokenDistributionCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/TokenDistributionCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/UsageTrendsCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/UsageTrendsCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/UsageTrendsCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Cards/YearlyCostHeatmapCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+Accessibility.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration+ColorThemes.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Configuration/HeatmapConfiguration.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/DaySquare.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGrid.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGridLayout.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/HeatmapGridPerformance.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Grid/WeekColumn.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend+Factory.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegend.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/HeatmapLegendBuilder.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Legend/LegendSquare.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ActivityLevel.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ActivityLevelLabels.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/BorderStyle.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/ColorScheme.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/DateConstants.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateConstants.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/DateConstants.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/DateRangeValidation.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/HeatmapData.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/HeatmapDateCalculator.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/MonthOperations.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Models/WeekOperations.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+DataGeneration.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore+SupportingTypes.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Stores/HeatmapStore.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip+Factory.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltip.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/HeatmapTooltipBuilder.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/TooltipConfiguration.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/Tooltip/TooltipPositioning.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Factories.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap+Preview.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Analytics/Heatmap/YearlyCostHeatmap.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Components/EmptyStateView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Components/EmptyStateView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Components/EmptyStateView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Daily/DailyUsageView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Daily/DailyUsageView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Daily/DailyUsageView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/MainView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/MainView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/MainView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Models/ModelsView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Models/ModelsView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Models/ModelsView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Overview/MetricCard.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/MetricCard.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Overview/MetricCard.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift b/Packages/ClaudeUsageUI/Sources/MainWindow/Overview/OverviewView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MainWindow/Overview/OverviewView.swift rename to Packages/ClaudeUsageUI/Sources/MainWindow/Overview/OverviewView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/ActionButtons.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ActionButtons.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/ActionButtons.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/GraphView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/GraphView.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/GraphView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/MetricRow.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/MetricRow.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/MetricRow.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/ProgressBar.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/ProgressBar.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/ProgressBar.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/SectionHeader.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SectionHeader.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/SectionHeader.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Components/SettingsMenu.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Components/SettingsMenu.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Components/SettingsMenu.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Helpers/ColorService.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/ColorService.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Helpers/ColorService.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Helpers/FormatterService.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Helpers/FormatterService.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Helpers/FormatterService.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/MenuBarContentView.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarContentView.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/MenuBarContentView.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/MenuBarScene.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/MenuBarScene.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/MenuBarScene.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Sections/CostMetricsSection.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/CostMetricsSection.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Sections/CostMetricsSection.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Sections/SessionMetricsSection.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/SessionMetricsSection.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Sections/SessionMetricsSection.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Sections/UsageMetricsSection.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Sections/UsageMetricsSection.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Sections/UsageMetricsSection.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Theme/MenuBarStyles.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarStyles.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Theme/MenuBarStyles.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift b/Packages/ClaudeUsageUI/Sources/MenuBar/Theme/MenuBarTheme.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/MenuBar/Theme/MenuBarTheme.swift rename to Packages/ClaudeUsageUI/Sources/MenuBar/Theme/MenuBarTheme.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift b/Packages/ClaudeUsageUI/Sources/Settings/AppSettingsService.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/AppSettingsService.swift rename to Packages/ClaudeUsageUI/Sources/Settings/AppSettingsService.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift b/Packages/ClaudeUsageUI/Sources/Settings/OpenAtLoginToggle.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Settings/OpenAtLoginToggle.swift rename to Packages/ClaudeUsageUI/Sources/Settings/OpenAtLoginToggle.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift b/Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyChartModels.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyChartModels.swift rename to Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyChartModels.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift b/Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift rename to Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyCostChartSimple.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift b/Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyTooltipViews.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Shared/Charts/HourlyChart/HourlyTooltipViews.swift rename to Packages/ClaudeUsageUI/Sources/Shared/Charts/HourlyChart/HourlyTooltipViews.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/ClockProtocol.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/ClockProtocol.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/ClockProtocol.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/SystemClock.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/SystemClock.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/SystemClock.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/TestClock.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Clock/TestClock.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Clock/TestClock.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/AppConfiguration.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/AppConfiguration.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/AppConfiguration.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/LoadTrace.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/LoadTrace.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/LoadTrace.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/SessionMonitorService.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/SessionMonitorService.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/SessionMonitorService.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/UsageDataLoader.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/Loading/UsageDataLoader.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/Loading/UsageDataLoader.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift b/Packages/ClaudeUsageUI/Sources/Stores/Services/RefreshCoordinator.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/Services/RefreshCoordinator.swift rename to Packages/ClaudeUsageUI/Sources/Stores/Services/RefreshCoordinator.swift diff --git a/Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift b/Packages/ClaudeUsageUI/Sources/Stores/UsageStore.swift similarity index 100% rename from Packages/ClaudeUsageUI/Sources/ClaudeUsageUI/Stores/UsageStore.swift rename to Packages/ClaudeUsageUI/Sources/Stores/UsageStore.swift From 7b3b9079339e4fbc315f6af272f34961e7b889f6 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:12:34 +0100 Subject: [PATCH 5/9] refactor: flatten directory structure across all packages - Add explicit path parameters to all targets - Move Sources//* to Sources/ - Move Tests/Tests/* to Tests/ - Reduce nesting depth for cleaner navigation --- Packages/ClaudeUsageCore/Package.swift | 6 ++++-- .../{ClaudeUsageCore => }/Analytics/PricingCalculator.swift | 0 .../{ClaudeUsageCore => }/Analytics/UsageAggregator.swift | 0 .../{ClaudeUsageCore => }/Analytics/UsageAnalytics.swift | 0 .../Sources/{ClaudeUsageCore => }/ClaudeUsageCore.swift | 0 .../Sources/{ClaudeUsageCore => }/Models/BurnRate.swift | 0 .../Sources/{ClaudeUsageCore => }/Models/SessionBlock.swift | 0 .../Sources/{ClaudeUsageCore => }/Models/TokenCounts.swift | 0 .../Sources/{ClaudeUsageCore => }/Models/UsageEntry.swift | 0 .../Sources/{ClaudeUsageCore => }/Models/UsageStats.swift | 0 .../{ClaudeUsageCore => }/Protocols/UsageDataSource.swift | 0 .../{ClaudeUsageCoreTests => }/PricingCalculatorTests.swift | 0 .../Tests/{ClaudeUsageCoreTests => }/TokenCountsTests.swift | 0 .../{ClaudeUsageCoreTests => }/UsageAggregatorTests.swift | 0 Packages/ClaudeUsageData/Package.swift | 6 ++++-- .../Sources/{ClaudeUsageData => }/ClaudeUsageData.swift | 0 .../{ClaudeUsageData => }/Monitoring/DirectoryMonitor.swift | 0 .../{ClaudeUsageData => }/Monitoring/SessionMonitor.swift | 0 .../Sources/{ClaudeUsageData => }/Parsing/JSONLParser.swift | 0 .../{ClaudeUsageData => }/Repository/FileDiscovery.swift | 0 .../{ClaudeUsageData => }/Repository/UsageRepository.swift | 0 .../{ClaudeUsageDataTests => }/FileDiscoveryTests.swift | 0 .../Tests/{ClaudeUsageDataTests => }/JSONLParserTests.swift | 0 .../{ClaudeUsageDataTests => }/SessionMonitorTests.swift | 0 .../{ClaudeUsageDataTests => }/UsageRepositoryTests.swift | 0 Packages/ClaudeUsageUI/Package.swift | 1 + .../Tests/{ClaudeUsageUITests => }/HeatmapStoreTests.swift | 0 .../{ClaudeUsageUITests => }/UsageDataLoaderTests.swift | 0 28 files changed, 9 insertions(+), 4 deletions(-) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Analytics/PricingCalculator.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Analytics/UsageAggregator.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Analytics/UsageAnalytics.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/ClaudeUsageCore.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Models/BurnRate.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Models/SessionBlock.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Models/TokenCounts.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Models/UsageEntry.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Models/UsageStats.swift (100%) rename Packages/ClaudeUsageCore/Sources/{ClaudeUsageCore => }/Protocols/UsageDataSource.swift (100%) rename Packages/ClaudeUsageCore/Tests/{ClaudeUsageCoreTests => }/PricingCalculatorTests.swift (100%) rename Packages/ClaudeUsageCore/Tests/{ClaudeUsageCoreTests => }/TokenCountsTests.swift (100%) rename Packages/ClaudeUsageCore/Tests/{ClaudeUsageCoreTests => }/UsageAggregatorTests.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/ClaudeUsageData.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/Monitoring/DirectoryMonitor.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/Monitoring/SessionMonitor.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/Parsing/JSONLParser.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/Repository/FileDiscovery.swift (100%) rename Packages/ClaudeUsageData/Sources/{ClaudeUsageData => }/Repository/UsageRepository.swift (100%) rename Packages/ClaudeUsageData/Tests/{ClaudeUsageDataTests => }/FileDiscoveryTests.swift (100%) rename Packages/ClaudeUsageData/Tests/{ClaudeUsageDataTests => }/JSONLParserTests.swift (100%) rename Packages/ClaudeUsageData/Tests/{ClaudeUsageDataTests => }/SessionMonitorTests.swift (100%) rename Packages/ClaudeUsageData/Tests/{ClaudeUsageDataTests => }/UsageRepositoryTests.swift (100%) rename Packages/ClaudeUsageUI/Tests/{ClaudeUsageUITests => }/HeatmapStoreTests.swift (100%) rename Packages/ClaudeUsageUI/Tests/{ClaudeUsageUITests => }/UsageDataLoaderTests.swift (100%) diff --git a/Packages/ClaudeUsageCore/Package.swift b/Packages/ClaudeUsageCore/Package.swift index 48264dd..2cfea26 100644 --- a/Packages/ClaudeUsageCore/Package.swift +++ b/Packages/ClaudeUsageCore/Package.swift @@ -15,10 +15,12 @@ let package = Package( targets: [ .target( name: "ClaudeUsageCore", - dependencies: []), + dependencies: [], + path: "Sources"), .testTarget( name: "ClaudeUsageCoreTests", - dependencies: ["ClaudeUsageCore"]), + dependencies: ["ClaudeUsageCore"], + path: "Tests"), ] ) diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift b/Packages/ClaudeUsageCore/Sources/Analytics/PricingCalculator.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/PricingCalculator.swift rename to Packages/ClaudeUsageCore/Sources/Analytics/PricingCalculator.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift b/Packages/ClaudeUsageCore/Sources/Analytics/UsageAggregator.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAggregator.swift rename to Packages/ClaudeUsageCore/Sources/Analytics/UsageAggregator.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift b/Packages/ClaudeUsageCore/Sources/Analytics/UsageAnalytics.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Analytics/UsageAnalytics.swift rename to Packages/ClaudeUsageCore/Sources/Analytics/UsageAnalytics.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/ClaudeUsageCore.swift b/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/ClaudeUsageCore.swift rename to Packages/ClaudeUsageCore/Sources/ClaudeUsageCore.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/BurnRate.swift b/Packages/ClaudeUsageCore/Sources/Models/BurnRate.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/BurnRate.swift rename to Packages/ClaudeUsageCore/Sources/Models/BurnRate.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/SessionBlock.swift b/Packages/ClaudeUsageCore/Sources/Models/SessionBlock.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/SessionBlock.swift rename to Packages/ClaudeUsageCore/Sources/Models/SessionBlock.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/TokenCounts.swift b/Packages/ClaudeUsageCore/Sources/Models/TokenCounts.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/TokenCounts.swift rename to Packages/ClaudeUsageCore/Sources/Models/TokenCounts.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageEntry.swift b/Packages/ClaudeUsageCore/Sources/Models/UsageEntry.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageEntry.swift rename to Packages/ClaudeUsageCore/Sources/Models/UsageEntry.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageStats.swift b/Packages/ClaudeUsageCore/Sources/Models/UsageStats.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Models/UsageStats.swift rename to Packages/ClaudeUsageCore/Sources/Models/UsageStats.swift diff --git a/Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift b/Packages/ClaudeUsageCore/Sources/Protocols/UsageDataSource.swift similarity index 100% rename from Packages/ClaudeUsageCore/Sources/ClaudeUsageCore/Protocols/UsageDataSource.swift rename to Packages/ClaudeUsageCore/Sources/Protocols/UsageDataSource.swift diff --git a/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift b/Packages/ClaudeUsageCore/Tests/PricingCalculatorTests.swift similarity index 100% rename from Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/PricingCalculatorTests.swift rename to Packages/ClaudeUsageCore/Tests/PricingCalculatorTests.swift diff --git a/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift b/Packages/ClaudeUsageCore/Tests/TokenCountsTests.swift similarity index 100% rename from Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/TokenCountsTests.swift rename to Packages/ClaudeUsageCore/Tests/TokenCountsTests.swift diff --git a/Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift b/Packages/ClaudeUsageCore/Tests/UsageAggregatorTests.swift similarity index 100% rename from Packages/ClaudeUsageCore/Tests/ClaudeUsageCoreTests/UsageAggregatorTests.swift rename to Packages/ClaudeUsageCore/Tests/UsageAggregatorTests.swift diff --git a/Packages/ClaudeUsageData/Package.swift b/Packages/ClaudeUsageData/Package.swift index f8b26a6..9130da7 100644 --- a/Packages/ClaudeUsageData/Package.swift +++ b/Packages/ClaudeUsageData/Package.swift @@ -18,10 +18,12 @@ let package = Package( targets: [ .target( name: "ClaudeUsageData", - dependencies: ["ClaudeUsageCore"]), + dependencies: ["ClaudeUsageCore"], + path: "Sources"), .testTarget( name: "ClaudeUsageDataTests", - dependencies: ["ClaudeUsageData"]), + dependencies: ["ClaudeUsageData"], + path: "Tests"), ] ) diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/ClaudeUsageData.swift b/Packages/ClaudeUsageData/Sources/ClaudeUsageData.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/ClaudeUsageData.swift rename to Packages/ClaudeUsageData/Sources/ClaudeUsageData.swift diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift b/Packages/ClaudeUsageData/Sources/Monitoring/DirectoryMonitor.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/DirectoryMonitor.swift rename to Packages/ClaudeUsageData/Sources/Monitoring/DirectoryMonitor.swift diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift b/Packages/ClaudeUsageData/Sources/Monitoring/SessionMonitor.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/Monitoring/SessionMonitor.swift rename to Packages/ClaudeUsageData/Sources/Monitoring/SessionMonitor.swift diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Parsing/JSONLParser.swift b/Packages/ClaudeUsageData/Sources/Parsing/JSONLParser.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/Parsing/JSONLParser.swift rename to Packages/ClaudeUsageData/Sources/Parsing/JSONLParser.swift diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/FileDiscovery.swift b/Packages/ClaudeUsageData/Sources/Repository/FileDiscovery.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/FileDiscovery.swift rename to Packages/ClaudeUsageData/Sources/Repository/FileDiscovery.swift diff --git a/Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/UsageRepository.swift b/Packages/ClaudeUsageData/Sources/Repository/UsageRepository.swift similarity index 100% rename from Packages/ClaudeUsageData/Sources/ClaudeUsageData/Repository/UsageRepository.swift rename to Packages/ClaudeUsageData/Sources/Repository/UsageRepository.swift diff --git a/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift b/Packages/ClaudeUsageData/Tests/FileDiscoveryTests.swift similarity index 100% rename from Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/FileDiscoveryTests.swift rename to Packages/ClaudeUsageData/Tests/FileDiscoveryTests.swift diff --git a/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/JSONLParserTests.swift b/Packages/ClaudeUsageData/Tests/JSONLParserTests.swift similarity index 100% rename from Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/JSONLParserTests.swift rename to Packages/ClaudeUsageData/Tests/JSONLParserTests.swift diff --git a/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/SessionMonitorTests.swift b/Packages/ClaudeUsageData/Tests/SessionMonitorTests.swift similarity index 100% rename from Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/SessionMonitorTests.swift rename to Packages/ClaudeUsageData/Tests/SessionMonitorTests.swift diff --git a/Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift b/Packages/ClaudeUsageData/Tests/UsageRepositoryTests.swift similarity index 100% rename from Packages/ClaudeUsageData/Tests/ClaudeUsageDataTests/UsageRepositoryTests.swift rename to Packages/ClaudeUsageData/Tests/UsageRepositoryTests.swift diff --git a/Packages/ClaudeUsageUI/Package.swift b/Packages/ClaudeUsageUI/Package.swift index b26b9a7..ac00a6f 100644 --- a/Packages/ClaudeUsageUI/Package.swift +++ b/Packages/ClaudeUsageUI/Package.swift @@ -31,6 +31,7 @@ let package = Package( "ClaudeUsageUI", "ClaudeUsageData", ], + path: "Tests", swiftSettings: [ .unsafeFlags(["-enable-testing"]), .define("ENABLE_CODE_COVERAGE", .when(configuration: .debug)), diff --git a/Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/HeatmapStoreTests.swift b/Packages/ClaudeUsageUI/Tests/HeatmapStoreTests.swift similarity index 100% rename from Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/HeatmapStoreTests.swift rename to Packages/ClaudeUsageUI/Tests/HeatmapStoreTests.swift diff --git a/Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/UsageDataLoaderTests.swift b/Packages/ClaudeUsageUI/Tests/UsageDataLoaderTests.swift similarity index 100% rename from Packages/ClaudeUsageUI/Tests/ClaudeUsageUITests/UsageDataLoaderTests.swift rename to Packages/ClaudeUsageUI/Tests/UsageDataLoaderTests.swift From 9872788e55792b3a9819f68f06662a26c42a00cf Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:39:48 +0100 Subject: [PATCH 6/9] chore: remove root Package.swift wrapper - Delete redundant root Package.swift and wrapper targets - Update Makefile to run tests from individual packages - Xcode project references packages directly in Packages/ --- ClaudeCodeUsage.xcodeproj/project.pbxproj | 2 + .../xcschemes/ClaudeCodeUsage.xcscheme | 154 ++++++++++++++++++ ClaudeCodeUsage.xctestplan | 40 +++++ Makefile | 14 +- Package.swift | 53 ------ .../xcschemes/ClaudeUsageUI.xcscheme | 67 ++++++++ .../Core/ClaudeUsageCoreWrapper.swift | 1 - .../Data/ClaudeUsageDataWrapper.swift | 1 - .../Wrappers/UI/ClaudeUsageUIWrapper.swift | 1 - 9 files changed, 274 insertions(+), 59 deletions(-) create mode 100644 ClaudeCodeUsage.xcodeproj/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme create mode 100644 ClaudeCodeUsage.xctestplan delete mode 100644 Package.swift create mode 100644 Packages/ClaudeUsageUI/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsageUI.xcscheme delete mode 100644 Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift delete mode 100644 Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift delete mode 100644 Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift diff --git a/ClaudeCodeUsage.xcodeproj/project.pbxproj b/ClaudeCodeUsage.xcodeproj/project.pbxproj index 64d12cd..8bab92f 100644 --- a/ClaudeCodeUsage.xcodeproj/project.pbxproj +++ b/ClaudeCodeUsage.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ ECF4102B2F03E6AD00DFC0C8 /* ClaudeCodeUsage.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ClaudeCodeUsage.app; sourceTree = BUILT_PRODUCTS_DIR; }; ECF410382F03E6AE00DFC0C8 /* ClaudeCodeUsageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaudeCodeUsageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; ECF410422F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ClaudeCodeUsageUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + ECF412B82F03F9B900DFC0C8 /* ClaudeCodeUsage.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = file; path = ClaudeCodeUsage.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -92,6 +93,7 @@ ECF410222F03E6AD00DFC0C8 = { isa = PBXGroup; children = ( + ECF412B82F03F9B900DFC0C8 /* ClaudeCodeUsage.xctestplan */, ECF4102D2F03E6AD00DFC0C8 /* ClaudeCodeUsage */, ECF4103B2F03E6AE00DFC0C8 /* ClaudeCodeUsageTests */, ECF410452F03E6AE00DFC0C8 /* ClaudeCodeUsageUITests */, diff --git a/ClaudeCodeUsage.xcodeproj/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme b/ClaudeCodeUsage.xcodeproj/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme new file mode 100644 index 0000000..73f6c05 --- /dev/null +++ b/ClaudeCodeUsage.xcodeproj/xcshareddata/xcschemes/ClaudeCodeUsage.xcscheme @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ClaudeCodeUsage.xctestplan b/ClaudeCodeUsage.xctestplan new file mode 100644 index 0000000..ddb54d2 --- /dev/null +++ b/ClaudeCodeUsage.xctestplan @@ -0,0 +1,40 @@ +{ + "configurations" : [ + { + "id" : "863E2FD1-1C91-46C5-B5FA-75CAF5654882", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "performanceAntipatternCheckerEnabled" : true, + "targetForVariableExpansion" : { + "containerPath" : "container:ClaudeCodeUsage.xcodeproj", + "identifier" : "ECF4102A2F03E6AD00DFC0C8", + "name" : "ClaudeCodeUsage" + } + }, + "testTargets" : [ + { + "enabled" : false, + "parallelizable" : true, + "target" : { + "containerPath" : "container:ClaudeCodeUsage.xcodeproj", + "identifier" : "ECF410372F03E6AE00DFC0C8", + "name" : "ClaudeCodeUsageTests" + } + }, + { + "enabled" : false, + "parallelizable" : true, + "target" : { + "containerPath" : "container:ClaudeCodeUsage.xcodeproj", + "identifier" : "ECF410412F03E6AE00DFC0C8", + "name" : "ClaudeCodeUsageUITests" + } + } + ], + "version" : 1 +} diff --git a/Makefile b/Makefile index 2ea56f6..5b518e4 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Makefile for ClaudeCodeUsage # Provides convenient commands for development -.PHONY: help build test coverage clean format lint docs +.PHONY: help build test test-core test-data test-ui coverage clean format lint docs # Default target help: @@ -23,8 +23,16 @@ build: swift build # Run all tests -test: - swift test --parallel +test: test-core test-data test-ui + +test-core: + swift test --package-path Packages/ClaudeUsageCore + +test-data: + swift test --package-path Packages/ClaudeUsageData + +test-ui: + swift test --package-path Packages/ClaudeUsageUI # Generate code coverage report coverage: diff --git a/Package.swift b/Package.swift deleted file mode 100644 index f3567fe..0000000 --- a/Package.swift +++ /dev/null @@ -1,53 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -let package = Package( - name: "ClaudeUsage", - platforms: [ - .macOS(.v15), - ], - products: [ - // Re-export products from standalone packages - .library( - name: "ClaudeUsageCore", - targets: ["ClaudeUsageCoreWrapper"]), - .library( - name: "ClaudeUsageData", - targets: ["ClaudeUsageDataWrapper"]), - .library( - name: "ClaudeUsageUI", - targets: ["ClaudeUsageUIWrapper"]), - ], - dependencies: [ - .package(path: "Packages/ClaudeUsageCore"), - .package(path: "Packages/ClaudeUsageData"), - .package(path: "Packages/ClaudeUsageUI"), - ], - targets: [ - // Thin wrappers that re-export standalone packages - .target( - name: "ClaudeUsageCoreWrapper", - dependencies: [ - .product(name: "ClaudeUsageCore", package: "ClaudeUsageCore"), - ], - path: "Sources/Wrappers/Core"), - - .target( - name: "ClaudeUsageDataWrapper", - dependencies: [ - .product(name: "ClaudeUsageData", package: "ClaudeUsageData"), - ], - path: "Sources/Wrappers/Data"), - - .target( - name: "ClaudeUsageUIWrapper", - dependencies: [ - .product(name: "ClaudeUsageUI", package: "ClaudeUsageUI"), - ], - path: "Sources/Wrappers/UI"), - ] -) - -// Note: To run the app, use: -// swift run --package-path Packages/ClaudeUsageUI ClaudeCodeUsage diff --git a/Packages/ClaudeUsageUI/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsageUI.xcscheme b/Packages/ClaudeUsageUI/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsageUI.xcscheme new file mode 100644 index 0000000..f9610b3 --- /dev/null +++ b/Packages/ClaudeUsageUI/.swiftpm/xcode/xcshareddata/xcschemes/ClaudeUsageUI.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift b/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift deleted file mode 100644 index 943c959..0000000 --- a/Sources/Wrappers/Core/ClaudeUsageCoreWrapper.swift +++ /dev/null @@ -1 +0,0 @@ -@_exported import ClaudeUsageCore diff --git a/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift b/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift deleted file mode 100644 index c851100..0000000 --- a/Sources/Wrappers/Data/ClaudeUsageDataWrapper.swift +++ /dev/null @@ -1 +0,0 @@ -@_exported import ClaudeUsageData diff --git a/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift b/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift deleted file mode 100644 index d3cd671..0000000 --- a/Sources/Wrappers/UI/ClaudeUsageUIWrapper.swift +++ /dev/null @@ -1 +0,0 @@ -@_exported import ClaudeUsageUI From d7595d96e62271d5b361327c4909e4db2992bbf5 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:43:17 +0100 Subject: [PATCH 7/9] fix(app): hide main window and dock icon on launch - Add LSUIElement=YES to make app an accessory/agent app - Window only appears when explicitly requested from menu bar --- ClaudeCodeUsage.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ClaudeCodeUsage.xcodeproj/project.pbxproj b/ClaudeCodeUsage.xcodeproj/project.pbxproj index 8bab92f..66c2a4b 100644 --- a/ClaudeCodeUsage.xcodeproj/project.pbxproj +++ b/ClaudeCodeUsage.xcodeproj/project.pbxproj @@ -434,6 +434,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -467,6 +468,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", From 93e22694989570b42f000cab869a0a5fce421ab3 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:49:42 +0100 Subject: [PATCH 8/9] feat(ui): add centralized preview catalog - Create Previews.swift as single entry point for all previews - Contains Menu Bar and Main Window previews in one file --- Packages/ClaudeUsageUI/Sources/Previews.swift | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 Packages/ClaudeUsageUI/Sources/Previews.swift diff --git a/Packages/ClaudeUsageUI/Sources/Previews.swift b/Packages/ClaudeUsageUI/Sources/Previews.swift new file mode 100644 index 0000000..357317c --- /dev/null +++ b/Packages/ClaudeUsageUI/Sources/Previews.swift @@ -0,0 +1,55 @@ +// +// Previews.swift +// All app previews in one place +// + +#if DEBUG +import SwiftUI +import ClaudeUsageCore + +// MARK: - Menu Bar Preview + +struct MenuBarPreviewWrapper: View { + @State private var store = UsageStore() + + var body: some View { + content + .frame(height: 500) + .task { await store.loadData() } + } + + @ViewBuilder + private var content: some View { + if store.state.hasLoaded { + MenuBarContentView(settingsService: AppSettingsService()) + .environment(store) + } else { + ProgressView("Loading...") + .frame(width: MenuBarTheme.Layout.menuBarWidth, height: 200) + } + } +} + +// MARK: - Main Window Preview + +struct MainWindowPreviewWrapper: View { + @State private var store = UsageStore() + + var body: some View { + MainView(settingsService: AppSettingsService()) + .environment(store) + .frame(width: 1000, height: 700) + .task { await store.loadData() } + } +} + +// MARK: - Previews + +#Preview("Menu Bar") { + MenuBarPreviewWrapper() +} + +#Preview("Main Window") { + MainWindowPreviewWrapper() +} +#endif From 19d9ecd36e20a6b7b5bcf2120103c2d72ac52902 Mon Sep 17 00:00:00 2001 From: webcpu Date: Tue, 30 Dec 2025 13:53:30 +0100 Subject: [PATCH 9/9] feat(ui): add combined preview showing all views side by side --- Packages/ClaudeUsageUI/Sources/Previews.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Packages/ClaudeUsageUI/Sources/Previews.swift b/Packages/ClaudeUsageUI/Sources/Previews.swift index 357317c..0903535 100644 --- a/Packages/ClaudeUsageUI/Sources/Previews.swift +++ b/Packages/ClaudeUsageUI/Sources/Previews.swift @@ -52,4 +52,12 @@ struct MainWindowPreviewWrapper: View { #Preview("Main Window") { MainWindowPreviewWrapper() } + +#Preview("All") { + HStack { + MenuBarPreviewWrapper() + Divider() + MainWindowPreviewWrapper() + } +} #endif