diff --git a/CHANGELOG.md b/CHANGELOG.md index 396cdf8..9b5e2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,54 @@ ## [Unreleased] +### Added + +- **Activity Report Tool Window** - New IDE tool window accessible from the status bar popup showing detailed activity breakdown: + - Tree-table view with daily activity organized by project + - Commit history display with hash and message + - Project filter dropdown to view all projects or a specific one + - Auto-refresh every 10 seconds to show live data + - Expand/collapse all functionality + +- **CSV Export for Invoicing** - Export activity data to CSV format: + - Date range selection dialog + - Includes date, project, hours (decimal), and commit descriptions + - Proper CSV escaping for special characters + +- **Git Branch and Commit Tracking** - Enhanced VCS integration: + - Track time spent per Git branch within each project + - Record commits with hash, message, author, timestamp, and changed files count + - Branch change listener to track branch switches + +- **Auto-Pause Settings** - Configure tracking behavior via "Auto-Pause..." in status bar popup: + - Toggle pause when IDE loses focus + - Configure inactivity timeout with minutes and seconds precision (10 sec - 60 min range) + +### Changed + +- **UTC Timezone Storage** - Local storage now uses UTC timezone for hour buckets: + - Consistent data storage regardless of timezone changes + - Automatic conversion to local timezone for display in Activity Report + +- **Idempotent Hub Sync** - Improved data sync reliability: + - Added `recordId` field for local storage records + - Prevents data duplication on double-sync while supporting multiple IDEs + - Live reporting still uses delta (ADD) mode for real-time updates + +- **Reworked Time Tracking Architecture** - Internal improvements: + - New `ProjectTimeAccumulator` for per-project time accumulation + - `CodingTimeCalculator` for total coding time calculations + - Better separation of concerns between tracking and reporting + +- **Analytics Event Types** - Cleaner analytics tracking: + - Unique event type constants for each trackable action + - Removed Map-based properties in favor of descriptive event names + +### Fixed + +- Activity Report now shows same totals as status bar widget (includes unsaved deltas) +- Improved data persistence during IDE shutdown with final flush + ## [1.5.2] - 2025-12-31 - Update README diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b4f2b42 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build Commands + +```bash +# Build the plugin +./gradlew build + +# Run IntelliJ IDE with the plugin installed for testing +./gradlew runIde + +# Format code (uses Google Java Format via Spotless) +./gradlew spotlessApply + +# Run tests +./gradlew test + +# Verify plugin compatibility +./gradlew verifyPlugin +``` + +## Architecture Overview + +CodeClocker is an IntelliJ IDEA plugin that tracks coding time and activity. It operates in two modes: +- **Local Mode** (default): All tracking data stays on the user's machine +- **Hub Mode** (optional): Syncs data to CodeClocker Hub web dashboards via API key + +### Core Components + +**Time Tracking Pipeline:** +- `FocusListener` (AWT event listener) → detects user activity in the IDE +- `TimeSpentActivityTracker` → manages activity detection with 2-minute inactivity timeout +- `TimeSpentPerProjectLogger` → accumulates time per project using `ProjectTimeAccumulator` +- `LocalStateRepository` → persists hourly activity snapshots to XML via IntelliJ's `PersistentStateComponent` +- `DataReportingTask` → scheduled task that reports accumulated data to Hub (if API key present) + +**VCS/Git Integration:** +- `ChangesActivityTracker` → tracks added/removed lines from VCS +- `GitCommitStatsListener` (CheckinHandlerFactory) → captures commit statistics +- `BranchActivityTracker` → tracks time per git branch +- Git features are optional via `git-features.xml` config that depends on `Git4Idea` + +**UI Components:** +- `TimeTrackerWidget` / `TimeTrackerWidgetFactory` → status bar widget showing daily time +- `TimeTrackerPopup` → popup panel with detailed stats and goal progress +- `BranchActivityToolWindowFactory` → tool window for branch activity reports + +**State Persistence:** +- Local state stored in `codeclocker-local-state.xml` with hourly granularity +- Data retained for max 2 weeks, auto-cleaned on load +- Hour keys use UTC timezone (migration from local timezone happens automatically) + +### Package Structure + +- `services/` - Core tracking services (time tracking, VCS changes, per-project accumulation) +- `local/` - Local state persistence and data models +- `reporting/` - Hub sync and HTTP clients for data reporting +- `widget/` - Status bar widget and popup UI +- `toolwindow/` - Branch activity tool window +- `goal/` - Daily/weekly goal tracking and notifications +- `analytics/` - Anonymous usage analytics +- `onboarding/` - First-run onboarding flow +- `apikey/` - API key management for Hub Mode +- `git/` - Git/VCS integration handlers + +### Key Design Patterns + +- Application-level services registered in `plugin.xml` for singleton tracking components +- Project-level services for project-specific state +- `ListenerRegistrator` is the main startup entry point that initializes all background tasks +- Scheduled tasks use a shared `ScheduledExecutor` thread pool + +## Code Style + +- Only add comments for complex logic; avoid commenting obvious code diff --git a/README.md b/README.md index 39f8672..9d5c702 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ -## CodeClocker - Automatic Time & Activity Tracker +## Automatic Time & Activity Tracker Plugin automatically tracks your **active coding time per project**, shows your progress in the **IDE status bar**, and helps you build consistent habits with **daily/weekly goals**. Use it **locally (offline)** by default, or optionally sync to **[CodeClocker Hub](https://hub.codeclocker.com/)** for **web dashboards** and **team analytics**. @@ -15,10 +15,12 @@ Use it **locally (offline)** by default, or optionally sync to **[CodeClocker Hu - **Automatic coding time tracking** - records active time while you work, organized by project. - **Daily & weekly goals** - set targets, monitor progress, and get notified when you reach them. +- **Activity Report** - detailed tree-view of daily activity with project breakdown and commit details. +- **CSV export** - export activity data for invoicing with date range selection. +- **Status bar widget** - see today's tracked time, current activity, and goal progress at a glance. +- **Auto-pause settings** - configure when tracking pauses (IDE focus lost, inactivity timeout). +- **Privacy** - all tracking data stays on your machine in Local Mode. - **VCS / Git insights** - tracks **added & removed lines** from version control activity. -- **Status bar widget** - see today’s tracked time, current activity, and goal progress at a glance. - -**Privacy:** All tracking data stays on your machine in Local Mode. ### Hub Mode (Optional - Web dashboards & team reporting) @@ -30,8 +32,25 @@ Enable Hub Mode with an API key to sync activity to **[CodeClocker Hub](https:// - **Contributor overview** (individual hours and activity) - **Project activity charts** (who contributed and when) - **Time range filtering** (e.g., last 7 days, custom periods) + - **Data storage** - Activity is synced to CodeClocker Hub only when you enable Hub Mode and provide an API key. + +### Activity Report + +Click the status bar widget and select **Activity Report...** to open a detailed view of your coding activity: + +- **Tree-table view** - Daily activity organized by project with expandable rows +- **Commit history** - See commits with hash and message for each project +- **Project filter** - View all projects or filter to a specific one +- **CSV export** - Export data for invoicing with customizable date range + +The Activity Report auto-refreshes every 10 seconds to show live data. + +### Auto-Pause Settings + +Click the status bar widget and select **Auto-Pause...** to configure tracking behavior: -**Data storage:** Activity is synced to CodeClocker Hub only when you enable Hub Mode and provide an API key. +- **Pause when IDE loses focus** - Automatically pause tracking when you switch to another application +- **Inactivity timeout** - Set how long to wait before pausing when there's no activity (10 seconds to 60 minutes) diff --git a/gradle.properties b/gradle.properties index 2504984..378982b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ platformVersion = 2025.2.5 # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP platformPlugins = # Example: platformBundledPlugins = com.intellij.java -platformBundledPlugins = +platformBundledPlugins = Git4Idea # Example: platformBundledModules = intellij.spellchecker platformBundledModules = diff --git a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java index de1b164..7ec1851 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java +++ b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java @@ -8,6 +8,7 @@ import com.codeclocker.plugin.intellij.listeners.FocusListener; import com.codeclocker.plugin.intellij.reporting.DataReportingTask; import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask; +import com.codeclocker.plugin.intellij.services.BranchActivityTracker; import com.codeclocker.plugin.intellij.subscription.SubscriptionStateCheckerTask; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.project.Project; @@ -44,9 +45,20 @@ public Object execute( return true; }); + // Initialize branch tracking for this project + initializeBranchTracking(project); + return null; } + private static void initializeBranchTracking(Project project) { + BranchActivityTracker branchTracker = + ApplicationManager.getApplication().getService(BranchActivityTracker.class); + if (branchTracker != null) { + branchTracker.initializeFromGit(project); + } + } + private static void registerFocusListener() { Toolkit.getDefaultToolkit().addAWTEventListener(new FocusListener(), FOCUS_EVENT_MASK); } diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java index 820f860..5a46cec 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java +++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java @@ -9,8 +9,8 @@ * Simple facade for tracking analytics events from anywhere in the plugin. Usage: * *
- *   Analytics.track(AnalyticsEventType.WIDGET_CLICK);
- *   Analytics.track(AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "pause"));
+ *   Analytics.track(AnalyticsEventType.STATUS_BAR_WIDGET_CLICK);
+ *   Analytics.track(AnalyticsEventType.POPUP_SET_GOALS_CLICK);
  * 
*/ public final class Analytics { diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java index df9223e..3e7b1af 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java +++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java @@ -1,35 +1,37 @@ package com.codeclocker.plugin.intellij.analytics; -/** Constants for analytics event types. Using constants instead of enum for extensibility. */ public final class AnalyticsEventType { private AnalyticsEventType() {} // Widget events public static final String STATUS_BAR_WIDGET_CLICK = "status_bar_widget_click"; - public static final String WIDGET_POPUP_OPEN = "widget_popup_open"; - public static final String WIDGET_POPUP_ACTION = "widget_popup_action"; - // API key events - public static final String API_KEY_ENTERED = "api_key_entered"; - public static final String API_KEY_CLEARED = "api_key_cleared"; - public static final String API_KEY_DIALOG_OPENED = "api_key_dialog_opened"; - public static final String API_KEY_DIALOG_CANCELLED = "api_key_dialog_cancelled"; + // Widget popup actions + public static final String POPUP_WEB_DASHBOARD_CLICK = "popup_web_dashboard_click"; + public static final String POPUP_SAVE_HISTORY_CLICK = "popup_save_history_click"; + public static final String POPUP_RENEW_SUBSCRIPTION_CLICK = "popup_renew_subscription_click"; + public static final String POPUP_SET_GOALS_CLICK = "popup_set_goals_click"; + public static final String POPUP_AUTO_PAUSE_CLICK = "popup_auto_pause_click"; + public static final String POPUP_ACTIVITY_REPORT_CLICK = "popup_activity_report_click"; // Plugin lifecycle events public static final String PLUGIN_STARTED = "plugin_started"; public static final String PLUGIN_STOPPED = "plugin_stopped"; - // Action events - public static final String ACTION_SHOW_STATISTICS = "action_show_statistics"; - - // Feature usage events - public static final String FEATURE_LOCAL_STORAGE_SYNC = "feature_local_storage_sync"; - - // Error events - public static final String ERROR_DATA_REPORT_FAILED = "error_data_report_failed"; - public static final String ERROR_API_REQUEST_FAILED = "error_api_request_failed"; - - // Onboarding tour user actions - public static final String TOUR_ACTION = "tour_action"; + // Onboarding tour events + public static final String TOUR_WELCOME_START = "tour_welcome_start"; + public static final String TOUR_WELCOME_SKIP = "tour_welcome_skip"; + public static final String TOUR_STATUS_BAR_NEXT = "tour_status_bar_next"; + public static final String TOUR_STATUS_BAR_SKIP = "tour_status_bar_skip"; + public static final String TOUR_ACTIVITY_POPUP_NEXT = "tour_activity_popup_next"; + public static final String TOUR_ACTIVITY_POPUP_SKIP = "tour_activity_popup_skip"; + public static final String TOUR_GOALS_SET = "tour_goals_set"; + public static final String TOUR_GOALS_LATER = "tour_goals_later"; + public static final String TOUR_GOALS_SKIP = "tour_goals_skip"; + public static final String TOUR_HUB_CONNECT = "tour_hub_connect"; + public static final String TOUR_HUB_SKIP = "tour_hub_skip"; + + // Goal events + public static final String SET_NEW_GOAL = "set_new_goal"; } diff --git a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java index be399f7..a94d142 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java +++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java @@ -34,22 +34,27 @@ public static void persistApiKey(String apiKey) { } private static void syncLocalDataToServer(String apiKey) { - try { - DataReportingTask dataReportingTask = - ApplicationManager.getApplication().getService(DataReportingTask.class); - if (dataReportingTask != null) { - dataReportingTask.syncLocalDataToServer(apiKey); - } - - // Refetch trends data now that local data has been synced - TimeComparisonFetchTask timeComparisonFetchTask = - ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class); - if (timeComparisonFetchTask != null) { - timeComparisonFetchTask.refetch(); - } - } catch (Exception e) { - LOG.warn("Failed to sync local data after API key was set", e); - } + // Run sync on background thread to avoid blocking UI + ApplicationManager.getApplication() + .executeOnPooledThread( + () -> { + try { + DataReportingTask dataReportingTask = + ApplicationManager.getApplication().getService(DataReportingTask.class); + if (dataReportingTask != null) { + dataReportingTask.syncLocalDataToServer(apiKey); + } + + // Refetch trends data now that local data has been synced + TimeComparisonFetchTask timeComparisonFetchTask = + ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class); + if (timeComparisonFetchTask != null) { + timeComparisonFetchTask.refetch(); + } + } catch (Exception e) { + LOG.warn("Failed to sync local data after API key was set", e); + } + }); } public static void unsetApiKey() { diff --git a/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java b/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java index 523bc89..134c06f 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java +++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java @@ -2,6 +2,7 @@ import static com.codeclocker.plugin.intellij.HubHost.HUB_UI_HOST; import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; import com.codeclocker.plugin.intellij.subscription.SubscriptionStateCheckerTask; import com.codeclocker.plugin.intellij.widget.TimeTrackerInitializer; @@ -23,12 +24,14 @@ public void actionPerformed(@NotNull AnActionEvent e) { public static void showAction() { String text = getText(); + boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); + int result = Messages.showOkCancelDialog( text, "Enter CodeClocker API Key", "Get API Key", - "Continue Without API Key", + hasApiKey ? "Cancel" : "Continue Without API Key", Messages.getInformationIcon()); if (result == Messages.YES) { BrowserUtil.browse(HUB_UI_HOST + "/api-key"); @@ -63,8 +66,6 @@ private static void showApiKeyInputDialog() { TimeTrackerInitializer.markApiKeyAsChanged(); ApiKeyPersistence.persistApiKey(apiKey); ApplicationManager.getApplication().getService(SubscriptionStateCheckerTask.class).schedule(); - - TimeTrackerInitializer.reinitializeTimerWidgetsRefetchingDataFromHub(); } } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/git/BranchChangeListener.java b/src/main/java/com/codeclocker/plugin/intellij/git/BranchChangeListener.java new file mode 100644 index 0000000..92ca4ad --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/git/BranchChangeListener.java @@ -0,0 +1,44 @@ +package com.codeclocker.plugin.intellij.git; + +import com.codeclocker.plugin.intellij.services.BranchActivityTracker; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.Project; +import git4idea.repo.GitRepository; +import git4idea.repo.GitRepositoryChangeListener; +import org.jetbrains.annotations.NotNull; + +/** + * Listens for git repository changes (including branch switches) and notifies + * BranchActivityTracker. + */ +public class BranchChangeListener implements GitRepositoryChangeListener { + + @Override + public void repositoryChanged(@NotNull GitRepository repository) { + Project project = repository.getProject(); + if (project.isDisposed()) { + return; + } + + String branchName = getBranchName(repository); + String projectName = project.getName(); + + BranchActivityTracker tracker = + ApplicationManager.getApplication().getService(BranchActivityTracker.class); + if (tracker != null) { + tracker.onBranchChange(projectName, branchName); + } + } + + private String getBranchName(GitRepository repo) { + if (repo.getCurrentBranch() != null) { + return repo.getCurrentBranch().getName(); + } + // Detached HEAD state - show short hash + String revision = repo.getCurrentRevision(); + if (revision != null && revision.length() > 7) { + return "detached:" + revision.substring(0, 7); + } + return "detached"; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/git/ChangesTrackingCheckinHandler.java b/src/main/java/com/codeclocker/plugin/intellij/git/ChangesTrackingCheckinHandler.java index 44d8c38..5eee16c 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/git/ChangesTrackingCheckinHandler.java +++ b/src/main/java/com/codeclocker/plugin/intellij/git/ChangesTrackingCheckinHandler.java @@ -1,7 +1,11 @@ package com.codeclocker.plugin.intellij.git; import com.codeclocker.plugin.intellij.git.LineDifferenceCalculator.LineDifferenceResult; +import com.codeclocker.plugin.intellij.local.CommitRecord; +import com.codeclocker.plugin.intellij.services.BranchActivityTracker; +import com.codeclocker.plugin.intellij.services.CommitActivityTracker; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.openapi.vcs.CheckinProjectPanel; @@ -9,7 +13,14 @@ import com.intellij.openapi.vcs.changes.Change; import com.intellij.openapi.vcs.changes.ContentRevision; import com.intellij.openapi.vcs.checkin.CheckinHandler; +import git4idea.commands.Git; +import git4idea.commands.GitCommand; +import git4idea.commands.GitCommandResult; +import git4idea.commands.GitLineHandler; +import git4idea.repo.GitRepository; +import git4idea.repo.GitRepositoryManager; import java.util.Collection; +import java.util.List; import org.jetbrains.annotations.Nullable; public class ChangesTrackingCheckinHandler extends CheckinHandler { @@ -57,6 +68,89 @@ public void checkinSuccessful() { LOG.debug("Error handling checking event: {}", ex.getMessage()); } } + + // Record commit details + recordCommitDetails(project, changes.size()); + } + + private void recordCommitDetails(Project project, int changedFilesCount) { + try { + CommitActivityTracker commitTracker = + ApplicationManager.getApplication().getService(CommitActivityTracker.class); + BranchActivityTracker branchTracker = + ApplicationManager.getApplication().getService(BranchActivityTracker.class); + + if (commitTracker == null) { + return; + } + + GitRepositoryManager gitManager = GitRepositoryManager.getInstance(project); + if (gitManager == null) { + return; + } + + List repos = gitManager.getRepositories(); + if (repos.isEmpty()) { + return; + } + + GitRepository repo = repos.get(0); + String hash = getLatestCommitHash(project, repo); + String author = getGitAuthor(project, repo); + String message = panel.getCommitMessage(); + String branch = + branchTracker != null ? branchTracker.getCurrentBranch(project.getName()) : null; + + // Truncate message to first line + if (message != null && message.contains("\n")) { + message = message.substring(0, message.indexOf("\n")); + } + + CommitRecord record = + new CommitRecord( + hash != null ? hash : "unknown", + message != null ? message : "", + author != null ? author : "unknown", + System.currentTimeMillis(), + changedFilesCount, + branch != null ? branch : "unknown"); + + commitTracker.recordCommit(project.getName(), record); + } catch (Exception e) { + LOG.warn("Failed to record commit details", e); + } + } + + @Nullable + private String getLatestCommitHash(Project project, GitRepository repo) { + try { + GitLineHandler handler = new GitLineHandler(project, repo.getRoot(), GitCommand.REV_PARSE); + handler.addParameters("--short", "HEAD"); + GitCommandResult result = Git.getInstance().runCommand(handler); + if (result.success()) { + List output = result.getOutput(); + return output.isEmpty() ? null : output.get(0).trim(); + } + } catch (Exception e) { + LOG.debug("Failed to get commit hash", e); + } + return null; + } + + @Nullable + private String getGitAuthor(Project project, GitRepository repo) { + try { + GitLineHandler handler = new GitLineHandler(project, repo.getRoot(), GitCommand.CONFIG); + handler.addParameters("user.name"); + GitCommandResult result = Git.getInstance().runCommand(handler); + if (result.success()) { + List output = result.getOutput(); + return output.isEmpty() ? null : output.get(0).trim(); + } + } catch (Exception e) { + LOG.debug("Failed to get git author", e); + } + return null; } private String getExtension(String relativePath) { diff --git a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalNotificationService.java b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalNotificationService.java index 00cfda5..90d6cd3 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalNotificationService.java +++ b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalNotificationService.java @@ -2,6 +2,8 @@ import static com.intellij.notification.NotificationType.INFORMATION; +import com.codeclocker.plugin.intellij.analytics.Analytics; +import com.codeclocker.plugin.intellij.analytics.AnalyticsEventType; import com.intellij.ide.DataManager; import com.intellij.notification.NotificationGroupManager; import com.intellij.openapi.actionSystem.CommonDataKeys; @@ -133,6 +135,7 @@ private void showDailyGoalNotification(GoalProgress progress) { + formatGoalTime(progress.goalSeconds()) + ".", INFORMATION) + .addAction(new SetNewGoalAction()) .notify(getCurrentProject()); }); } @@ -149,10 +152,26 @@ private void showWeeklyGoalNotification(GoalProgress progress) { + formatGoalTime(progress.goalSeconds()) + ".", INFORMATION) + .addAction(new SetNewGoalAction()) .notify(getCurrentProject()); }); } + private static class SetNewGoalAction extends com.intellij.notification.NotificationAction { + SetNewGoalAction() { + super("Set New Goal"); + } + + @Override + public void actionPerformed( + com.intellij.openapi.actionSystem.AnActionEvent e, + com.intellij.notification.Notification notification) { + Analytics.track(AnalyticsEventType.SET_NEW_GOAL); + notification.expire(); + GoalSettingsDialog.showDialog(); + } + } + private String formatGoalTime(long seconds) { long hours = seconds / 3600; long minutes = (seconds % 3600) / 60; diff --git a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalPersistence.java b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalPersistence.java index 5498418..b568488 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalPersistence.java +++ b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalPersistence.java @@ -16,11 +16,6 @@ public class GoalPersistence { private static final int DEFAULT_DAILY_GOAL_MINUTES = 60; // 1 hour private static final int DEFAULT_WEEKLY_GOAL_MINUTES = 300; // 5 hours - /** - * Get the daily coding time goal in minutes. - * - * @return goal in minutes (defaults to 60 minutes / 1 hour) - */ public static int getDailyGoalMinutes() { return PropertiesComponent.getInstance().getInt(DAILY_GOAL_MINUTES, DEFAULT_DAILY_GOAL_MINUTES); } @@ -35,11 +30,6 @@ public static void setDailyGoalMinutes(int minutes) { .setValue(DAILY_GOAL_MINUTES, minutes, DEFAULT_DAILY_GOAL_MINUTES); } - /** - * Get the weekly coding time goal in minutes. - * - * @return goal in minutes (defaults to 300 minutes / 5 hours) - */ public static int getWeeklyGoalMinutes() { return PropertiesComponent.getInstance() .getInt(WEEKLY_GOAL_MINUTES, DEFAULT_WEEKLY_GOAL_MINUTES); diff --git a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalService.java b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalService.java index 4d6050e..da3774c 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalService.java +++ b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalService.java @@ -1,23 +1,14 @@ package com.codeclocker.plugin.intellij.goal; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_INIT_SECONDS; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_STOP_WATCH; - -import com.codeclocker.plugin.intellij.local.LocalStateRepository; -import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; +import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.components.Service; -import java.time.DayOfWeek; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Map; /** Application-level service for calculating goal progress. */ @Service(Service.Level.APP) public final class GoalService { - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - /** * Get the current daily goal progress. Always returns progress using configured or default goal. */ @@ -42,48 +33,36 @@ public GoalProgress getWeeklyProgress() { /** Get total coded seconds for today from live tracking. */ private long getTotalSecondsToday() { - return GLOBAL_INIT_SECONDS.get() + GLOBAL_STOP_WATCH.getSeconds(); + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + if (logger == null) { + return 0; + } + return logger.getGlobalAccumulatedToday(); } /** - * Get total coded seconds for the current week (Monday to today). Combines historical data from - * LocalStateRepository with today's live tracking. + * Get total coded seconds for the current week (Monday to today). Uses LocalActivityDataProvider + * which returns data in local timezone. */ private long getTotalSecondsThisWeek() { - LocalDate today = LocalDate.now(); - LocalDate weekStart = today.with(DayOfWeek.MONDAY); - - long historicalSeconds = sumSecondsFromLocalStorage(weekStart, today.minusDays(1)); - long todaySeconds = getTotalSecondsToday(); - - return historicalSeconds + todaySeconds; - } - - /** Sum coded seconds from local storage for a date range (inclusive). */ - private long sumSecondsFromLocalStorage(LocalDate startDate, LocalDate endDate) { - LocalStateRepository repository = - ApplicationManager.getApplication().getService(LocalStateRepository.class); - - if (repository == null) { - return 0; + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + if (dataProvider == null) { + return getTotalSecondsToday(); } - Map> allData = repository.getAllData(); - long total = 0; - - for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) { - String datePrefix = date.format(DATE_FORMATTER); + // Get week total from provider (includes historical data in local timezone) + // Then add any unsaved deltas from today's live tracking + long weekSeconds = dataProvider.getWeekTotalSeconds(); - for (Map.Entry> hourEntry : allData.entrySet()) { - String hourKey = hourEntry.getKey(); - if (hourKey.startsWith(datePrefix)) { - for (ProjectActivitySnapshot snapshot : hourEntry.getValue().values()) { - total += snapshot.getCodedTimeSeconds(); - } - } - } + // Add unsaved deltas that haven't been persisted yet + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + if (logger != null) { + weekSeconds += logger.getGlobalUnsavedDelta(); } - return total; + return weekSeconds; } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalSettingsDialog.java b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalSettingsDialog.java index ab8a779..e24232c 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/goal/GoalSettingsDialog.java +++ b/src/main/java/com/codeclocker/plugin/intellij/goal/GoalSettingsDialog.java @@ -1,6 +1,7 @@ package com.codeclocker.plugin.intellij.goal; import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.ui.ValidationInfo; import com.intellij.ui.components.JBCheckBox; import com.intellij.ui.components.JBLabel; import com.intellij.util.ui.FormBuilder; @@ -72,6 +73,29 @@ public GoalSettingsDialog() { .getPanel(); } + @Override + protected @Nullable ValidationInfo doValidate() { + int dailyHours = (Integer) dailyHoursSpinner.getValue(); + int dailyMins = (Integer) dailyMinutesSpinner.getValue(); + int dailyTotal = dailyHours * 60 + dailyMins; + + int weeklyHours = (Integer) weeklyHoursSpinner.getValue(); + int weeklyMins = (Integer) weeklyMinutesSpinner.getValue(); + int weeklyTotal = weeklyHours * 60 + weeklyMins; + + // Daily goal cannot exceed 24 hours (1440 minutes) + if (dailyTotal > 1440) { + return new ValidationInfo("Daily goal cannot exceed 24 hours", dailyHoursSpinner); + } + + // Weekly goal cannot exceed 168 hours (10080 minutes) + if (weeklyTotal > 10080) { + return new ValidationInfo("Weekly goal cannot exceed 168 hours", weeklyHoursSpinner); + } + + return null; + } + @Override protected void doOKAction() { int dailyHours = (Integer) dailyHoursSpinner.getValue(); diff --git a/src/main/java/com/codeclocker/plugin/intellij/listeners/AppFrameFocusLostListener.java b/src/main/java/com/codeclocker/plugin/intellij/listeners/AppFrameFocusLostListener.java index f3ad266..1d92a7d 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/listeners/AppFrameFocusLostListener.java +++ b/src/main/java/com/codeclocker/plugin/intellij/listeners/AppFrameFocusLostListener.java @@ -1,6 +1,7 @@ package com.codeclocker.plugin.intellij.listeners; import com.codeclocker.plugin.intellij.services.TimeSpentActivityTracker; +import com.codeclocker.plugin.intellij.tracking.TrackingPersistence; import com.intellij.openapi.application.ApplicationActivationListener; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; @@ -19,6 +20,10 @@ public AppFrameFocusLostListener() { @Override public void applicationDeactivated(@NotNull IdeFrame ideFrame) { + if (!TrackingPersistence.isPauseOnFocusLostEnabled()) { + LOG.debug("Application frame lost focus, but pause on focus lost is disabled"); + return; + } LOG.debug("Application frame lost focus. Pausing all activity tracking"); tracker.pause(); } diff --git a/src/main/java/com/codeclocker/plugin/intellij/listeners/PauseProjectOnProjectClosing.java b/src/main/java/com/codeclocker/plugin/intellij/listeners/PauseProjectOnProjectClosing.java index 9e5674c..7e26ba6 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/listeners/PauseProjectOnProjectClosing.java +++ b/src/main/java/com/codeclocker/plugin/intellij/listeners/PauseProjectOnProjectClosing.java @@ -20,6 +20,6 @@ public PauseProjectOnProjectClosing() { @Override public void projectClosing(@NotNull Project project) { LOG.debug("Project closing: " + project.getName()); - logger.pauseProject(project); + logger.closeProject(project); } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/BranchActivityRecord.java b/src/main/java/com/codeclocker/plugin/intellij/local/BranchActivityRecord.java new file mode 100644 index 0000000..6b936de --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/local/BranchActivityRecord.java @@ -0,0 +1,33 @@ +package com.codeclocker.plugin.intellij.local; + +/** Record of time spent on a specific git branch during an hour. */ +public class BranchActivityRecord { + + private String branchName; + private long activeSeconds; + + public BranchActivityRecord() { + // Required for XML serialization + } + + public BranchActivityRecord(String branchName, long activeSeconds) { + this.branchName = branchName; + this.activeSeconds = activeSeconds; + } + + public String getBranchName() { + return branchName; + } + + public void setBranchName(String branchName) { + this.branchName = branchName; + } + + public long getActiveSeconds() { + return activeSeconds; + } + + public void setActiveSeconds(long activeSeconds) { + this.activeSeconds = activeSeconds; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/CommitRecord.java b/src/main/java/com/codeclocker/plugin/intellij/local/CommitRecord.java new file mode 100644 index 0000000..0bb1528 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/local/CommitRecord.java @@ -0,0 +1,79 @@ +package com.codeclocker.plugin.intellij.local; + +/** Record of a single git commit with full details. */ +public class CommitRecord { + + private String hash; + private String message; + private String author; + private long timestamp; + private int changedFilesCount; + private String branch; + + public CommitRecord() { + // Required for XML serialization + } + + public CommitRecord( + String hash, + String message, + String author, + long timestamp, + int changedFilesCount, + String branch) { + this.hash = hash; + this.message = message; + this.author = author; + this.timestamp = timestamp; + this.changedFilesCount = changedFilesCount; + this.branch = branch; + } + + public String getHash() { + return hash; + } + + public void setHash(String hash) { + this.hash = hash; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public long getTimestamp() { + return timestamp; + } + + public void setTimestamp(long timestamp) { + this.timestamp = timestamp; + } + + public int getChangedFilesCount() { + return changedFilesCount; + } + + public void setChangedFilesCount(int changedFilesCount) { + this.changedFilesCount = changedFilesCount; + } + + public String getBranch() { + return branch; + } + + public void setBranch(String branch) { + this.branch = branch; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java new file mode 100644 index 0000000..41bde4d --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java @@ -0,0 +1,153 @@ +package com.codeclocker.plugin.intellij.local; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Provides local activity data converted to the user's local timezone for display purposes. This is + * the single source of truth for all UI components that need to display coding time data. + * + *

Internally, data is stored in UTC. This provider converts UTC hourKeys to local timezone when + * returning data for display. + */ +@Service(Service.Level.APP) +public final class LocalActivityDataProvider { + + private static final DateTimeFormatter DATETIME_HOUR_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + private static final ZoneId UTC = ZoneId.of("UTC"); + + private LocalStateRepository getRepository() { + return ApplicationManager.getApplication().getService(LocalStateRepository.class); + } + + /** + * Returns all activity data with hourKeys converted to local timezone. The returned map is sorted + * by hourKey in descending order (most recent first). + * + * @return Map of localHourKey -> (projectName -> snapshot) + */ + public Map> getAllDataInLocalTimezone() { + Map> utcData = getRepository().getAllData(); + return convertToLocalTimezone(utcData); + } + + /** + * Get total coded seconds for today across all projects. + * + * @return total seconds coded today in local timezone + */ + public long getTodayTotalSeconds() { + String todayLocalPrefix = LocalDate.now().toString(); + Map> localData = getAllDataInLocalTimezone(); + + return localData.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(todayLocalPrefix)) + .flatMap(entry -> entry.getValue().values().stream()) + .mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds) + .sum(); + } + + /** + * Get total coded seconds for today for a specific project. + * + * @param projectName the project name + * @return total seconds coded today for the project in local timezone + */ + public long getTodayProjectSeconds(String projectName) { + String todayLocalPrefix = LocalDate.now().toString(); + Map> localData = getAllDataInLocalTimezone(); + + return localData.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(todayLocalPrefix)) + .map(entry -> entry.getValue().get(projectName)) + .filter(snapshot -> snapshot != null) + .mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds) + .sum(); + } + + /** + * Get total coded seconds for the current week (Monday to Sunday) across all projects. + * + * @return total seconds coded this week in local timezone + */ + public long getWeekTotalSeconds() { + LocalDate today = LocalDate.now(); + LocalDate weekStart = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + Map> localData = getAllDataInLocalTimezone(); + + return localData.entrySet().stream() + .filter(entry -> isInWeek(entry.getKey(), weekStart, today)) + .flatMap(entry -> entry.getValue().values().stream()) + .mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds) + .sum(); + } + + private boolean isInWeek(String hourKey, LocalDate weekStart, LocalDate today) { + try { + String dateStr = hourKey.substring(0, 10); // "yyyy-MM-dd" + LocalDate date = LocalDate.parse(dateStr); + return !date.isBefore(weekStart) && !date.isAfter(today); + } catch (Exception e) { + return false; + } + } + + /** + * Converts a map with UTC hourKeys to local timezone hourKeys. + * + * @param utcData data with UTC hourKeys + * @return data with local timezone hourKeys, sorted by key descending + */ + private Map> convertToLocalTimezone( + Map> utcData) { + + ZoneId localZone = ZoneId.systemDefault(); + Map> localData = new LinkedHashMap<>(); + + // Convert and collect + for (Map.Entry> entry : utcData.entrySet()) { + String utcHourKey = entry.getKey(); + String localHourKey = convertUtcHourKeyToLocal(utcHourKey, localZone); + + // Merge in case multiple UTC hours map to same local hour (shouldn't happen normally) + localData.compute( + localHourKey, + (key, existingProjects) -> { + if (existingProjects == null) { + return new LinkedHashMap<>(entry.getValue()); + } + existingProjects.putAll(entry.getValue()); + return existingProjects; + }); + } + + // Sort by key descending (most recent first) + return localData.entrySet().stream() + .sorted(Map.Entry.>comparingByKey().reversed()) + .collect( + LinkedHashMap::new, + (map, e) -> map.put(e.getKey(), e.getValue()), + LinkedHashMap::putAll); + } + + private String convertUtcHourKeyToLocal(String utcHourKey, ZoneId localZone) { + try { + LocalDateTime utcDateTime = LocalDateTime.parse(utcHourKey, DATETIME_HOUR_FORMATTER); + ZonedDateTime localDateTime = utcDateTime.atZone(UTC).withZoneSameInstant(localZone); + return localDateTime.format(DATETIME_HOUR_FORMATTER); + } catch (Exception e) { + // Fallback to original if parsing fails + return utcHourKey; + } + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java index bfc5941..26af988 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java @@ -7,7 +7,10 @@ import com.intellij.openapi.components.Storage; import com.intellij.openapi.diagnostic.Logger; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.HashMap; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -37,6 +40,18 @@ public class LocalStateRepository implements PersistentStateComponent 0) { + LOG.info("Generated recordIds for " + recordIdsGenerated + " existing entries"); + } + // Cleanup old entries on load int removed = this.state.cleanupOldEntries(); if (removed > 0) { @@ -45,10 +60,74 @@ public void loadState(@NotNull LocalTrackerState state) { LOG.debug("Loaded local tracker state with " + this.state.getTotalEntries() + " entries"); } + /** + * Migrates all hourKeys from local timezone to UTC. This is a one-time migration for existing + * data that was stored using the host's timezone. + */ + private void migrateHourKeysToUtc() { + Map> oldData = state.getHourlyActivity(); + if (oldData.isEmpty()) { + state.setHourKeyTimezone(LocalTrackerState.TIMEZONE_UTC); + LOG.info("No data to migrate, setting timezone to UTC"); + return; + } + + LOG.info("Migrating " + oldData.size() + " hour entries from local timezone to UTC"); + + Map> migratedData = new HashMap<>(); + ZoneId localZone = ZoneId.systemDefault(); + + for (Map.Entry> entry : oldData.entrySet()) { + String localHourKey = entry.getKey(); + String utcHourKey = convertLocalHourKeyToUtc(localHourKey, localZone); + + // Merge into migrated data (in case of collision, though unlikely) + migratedData.compute( + utcHourKey, + (key, existingProjects) -> { + if (existingProjects == null) { + return new HashMap<>(entry.getValue()); + } + // Merge projects if collision occurs + for (Map.Entry projectEntry : + entry.getValue().entrySet()) { + existingProjects.merge( + projectEntry.getKey(), + projectEntry.getValue(), + (existing, incoming) -> + new ProjectActivitySnapshot( + existing.getCodedTimeSeconds() + incoming.getCodedTimeSeconds(), + existing.getAdditions() + incoming.getAdditions(), + existing.getRemovals() + incoming.getRemovals(), + existing.isReported() && incoming.isReported())); + } + return existingProjects; + }); + } + + state.setHourlyActivity(migratedData); + state.setHourKeyTimezone(LocalTrackerState.TIMEZONE_UTC); + LOG.info("Migration complete. Converted " + oldData.size() + " entries to UTC"); + } + + private String convertLocalHourKeyToUtc(String localHourKey, ZoneId localZone) { + try { + LocalDateTime localDateTime = LocalDateTime.parse(localHourKey, DATETIME_HOUR_FORMATTER); + ZonedDateTime utcDateTime = + localDateTime.atZone(localZone).withZoneSameInstant(ZoneId.of("UTC")); + return utcDateTime.format(DATETIME_HOUR_FORMATTER); + } catch (Exception e) { + LOG.warn("Failed to convert hourKey to UTC: " + localHourKey, e); + return localHourKey; + } + } + public void mergeProjectCurrentHour(String projectName, ProjectActivitySnapshot snapshot) { - String currentHour = LocalDateTime.now().format(DATETIME_HOUR_FORMATTER); - state.mergeProject(currentHour, projectName, snapshot); - LOG.debug("Merged local state for project: " + projectName + " at hour: " + currentHour); + // Ensure snapshot has a recordId for idempotent sync + snapshot.ensureRecordId(); + String currentUtcHour = ZonedDateTime.now(ZoneId.of("UTC")).format(DATETIME_HOUR_FORMATTER); + state.mergeProject(currentUtcHour, projectName, snapshot); + LOG.debug("Merged local state for project: " + projectName + " at UTC hour: " + currentUtcHour); } public Map> getAllData() { diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java index 04d4da7..7544221 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java @@ -2,23 +2,48 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Predicate; /** * State class for local persistence of tracked time and VCS changes. Structure: datetime - * (YYYY-MM-DD-HH) -> project name -> activity snapshot. Data is retained for a maximum of 2 weeks. + * (YYYY-MM-DD-HH in UTC) -> project name -> activity snapshot. Data is retained for a maximum of 2 + * weeks. */ public class LocalTrackerState { + public static final String TIMEZONE_UTC = "UTC"; + private static final int RETENTION_DAYS = 14; private static final DateTimeFormatter DATETIME_HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + /** + * Timezone of hourKeys in hourlyActivity. null = legacy local timezone (needs migration), "UTC" = + * migrated to UTC. + */ + private String hourKeyTimezone; + private Map> hourlyActivity = new HashMap<>(); + public String getHourKeyTimezone() { + return hourKeyTimezone; + } + + public void setHourKeyTimezone(String hourKeyTimezone) { + this.hourKeyTimezone = hourKeyTimezone; + } + + public boolean needsMigrationToUtc() { + return hourKeyTimezone == null || !TIMEZONE_UTC.equals(hourKeyTimezone); + } + public Map> getHourlyActivity() { return hourlyActivity; } @@ -38,12 +63,50 @@ public void mergeProject( projects.merge( projectName, newSnapshot, - (existing, incoming) -> - new ProjectActivitySnapshot( - existing.getCodedTimeSeconds() + incoming.getCodedTimeSeconds(), - existing.getAdditions() + incoming.getAdditions(), - existing.getRemovals() + incoming.getRemovals(), - existing.isReported())); + (existing, incoming) -> { + ProjectActivitySnapshot merged = + new ProjectActivitySnapshot( + existing.getCodedTimeSeconds() + incoming.getCodedTimeSeconds(), + existing.getAdditions() + incoming.getAdditions(), + existing.getRemovals() + incoming.getRemovals(), + existing.isReported()); + + // Preserve recordId from existing entry (or use incoming's if existing has none) + String recordId = existing.getRecordId(); + if (recordId == null || recordId.isEmpty()) { + recordId = incoming.getRecordId(); + } + merged.setRecordId(recordId); + + // Merge branch activity (sum seconds per branch) + Map branchMap = new HashMap<>(); + for (BranchActivityRecord b : existing.getBranchActivity()) { + branchMap.merge(b.getBranchName(), b.getActiveSeconds(), Long::sum); + } + for (BranchActivityRecord b : incoming.getBranchActivity()) { + branchMap.merge(b.getBranchName(), b.getActiveSeconds(), Long::sum); + } + List mergedBranches = new ArrayList<>(); + for (Map.Entry e : branchMap.entrySet()) { + mergedBranches.add(new BranchActivityRecord(e.getKey(), e.getValue())); + } + merged.setBranchActivity(mergedBranches); + + // Merge commits (dedupe by hash) + Set existingHashes = new HashSet<>(); + for (CommitRecord c : existing.getCommits()) { + existingHashes.add(c.getHash()); + } + List mergedCommits = new ArrayList<>(existing.getCommits()); + for (CommitRecord c : incoming.getCommits()) { + if (!existingHashes.contains(c.getHash())) { + mergedCommits.add(c); + } + } + merged.setCommits(mergedCommits); + + return merged; + }); return projects; }); } @@ -107,4 +170,23 @@ public Map> getUnreportedData() { } return unreported; } + + /** + * Ensures all snapshots have recordIds. Used during migration for existing entries that were + * created before recordId was introduced. + * + * @return number of recordIds generated + */ + public int ensureAllRecordIds() { + int generated = 0; + for (Map projects : hourlyActivity.values()) { + for (ProjectActivitySnapshot snapshot : projects.values()) { + if (snapshot.getRecordId() == null || snapshot.getRecordId().isEmpty()) { + snapshot.ensureRecordId(); + generated++; + } + } + } + return generated; + } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java b/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java index ab3deb9..75bae64 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java @@ -1,15 +1,22 @@ package com.codeclocker.plugin.intellij.local; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + /** - * Snapshot of activity data for a single project. Stores coded time in seconds and VCS change - * counts. + * Snapshot of activity data for a single project. Stores coded time in seconds, VCS change counts, + * branch activity, and commit records. */ public class ProjectActivitySnapshot { + private String recordId; private long codedTimeSeconds; private long additions; private long removals; private boolean reported; + private List branchActivity = new ArrayList<>(); + private List commits = new ArrayList<>(); public ProjectActivitySnapshot() { // Required for XML serialization @@ -54,4 +61,35 @@ public boolean isReported() { public void setReported(boolean reported) { this.reported = reported; } + + public List getBranchActivity() { + return branchActivity; + } + + public void setBranchActivity(List branchActivity) { + this.branchActivity = branchActivity != null ? branchActivity : new ArrayList<>(); + } + + public List getCommits() { + return commits; + } + + public void setCommits(List commits) { + this.commits = commits != null ? commits : new ArrayList<>(); + } + + public String getRecordId() { + return recordId; + } + + public void setRecordId(String recordId) { + this.recordId = recordId; + } + + /** Ensures this snapshot has a recordId, generating one if not present. */ + public void ensureRecordId() { + if (recordId == null || recordId.isEmpty()) { + recordId = UUID.randomUUID().toString(); + } + } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/onboarding/OnboardingStepRenderer.java b/src/main/java/com/codeclocker/plugin/intellij/onboarding/OnboardingStepRenderer.java index 5f0a831..be4cbe4 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/onboarding/OnboardingStepRenderer.java +++ b/src/main/java/com/codeclocker/plugin/intellij/onboarding/OnboardingStepRenderer.java @@ -13,14 +13,11 @@ import com.intellij.openapi.project.Project; import com.intellij.openapi.wm.StatusBar; import com.intellij.openapi.wm.WindowManager; -import java.util.Map; -/** Renders each onboarding step with appropriate UI components. */ public final class OnboardingStepRenderer { private OnboardingStepRenderer() {} - /** Render the specified onboarding step. */ public static void renderStep(OnboardingStep step, Project project, OnboardingService service) { switch (step) { case WELCOME -> showWelcomeStep(project, service); @@ -32,11 +29,6 @@ public static void renderStep(OnboardingStep step, Project project, OnboardingSe } } - private static void trackAction(String action, OnboardingStep step) { - Analytics.track(AnalyticsEventType.TOUR_ACTION, Map.of("action", action, "step", step.name())); - } - - /** Step 1: Welcome notification introducing CodeClocker. */ private static void showWelcomeStep(Project project, OnboardingService service) { Notification notification = NotificationGroupManager.getInstance() @@ -51,21 +43,20 @@ private static void showWelcomeStep(Project project, OnboardingService service) NotificationAction.createSimpleExpiring( "Start tour", () -> { - trackAction("start_tour", OnboardingStep.WELCOME); + Analytics.track(AnalyticsEventType.TOUR_WELCOME_START); service.nextStep(); })); notification.addAction( NotificationAction.createSimpleExpiring( "Skip tour", () -> { - trackAction("skip_tour", OnboardingStep.WELCOME); + Analytics.track(AnalyticsEventType.TOUR_WELCOME_SKIP); service.skipOnboarding(); })); notification.notify(project); } - /** Step 2: Highlight the status bar widget using a notification with instructions. */ private static void showStatusBarWidgetStep(Project project, OnboardingService service) { StatusBar statusBar = WindowManager.getInstance().getStatusBar(project); if (statusBar == null) { @@ -90,21 +81,20 @@ private static void showStatusBarWidgetStep(Project project, OnboardingService s NotificationAction.createSimpleExpiring( "Got it, Next", () -> { - trackAction("next", OnboardingStep.STATUS_BAR_WIDGET); + Analytics.track(AnalyticsEventType.TOUR_STATUS_BAR_NEXT); service.nextStep(); })); notification.addAction( NotificationAction.createSimpleExpiring( "Skip tour", () -> { - trackAction("skip_tour", OnboardingStep.STATUS_BAR_WIDGET); + Analytics.track(AnalyticsEventType.TOUR_STATUS_BAR_SKIP); service.skipOnboarding(); })); notification.notify(project); } - /** Step 3: Explain the activity popup. */ private static void showActivityPopupStep(Project project, OnboardingService service) { Notification notification = NotificationGroupManager.getInstance() @@ -123,21 +113,20 @@ private static void showActivityPopupStep(Project project, OnboardingService ser NotificationAction.createSimpleExpiring( "Got it, Next", () -> { - trackAction("next", OnboardingStep.ACTIVITY_POPUP); + Analytics.track(AnalyticsEventType.TOUR_ACTIVITY_POPUP_NEXT); service.nextStep(); })); notification.addAction( NotificationAction.createSimpleExpiring( "Skip tour", () -> { - trackAction("skip_tour", OnboardingStep.ACTIVITY_POPUP); + Analytics.track(AnalyticsEventType.TOUR_ACTIVITY_POPUP_SKIP); service.skipOnboarding(); })); notification.notify(project); } - /** Step 4: Introduce goal setting. */ private static void showGoalsStep(Project project, OnboardingService service) { Notification notification = NotificationGroupManager.getInstance() @@ -153,7 +142,7 @@ private static void showGoalsStep(Project project, OnboardingService service) { NotificationAction.createSimpleExpiring( "Set goals now", () -> { - trackAction("set_goals", OnboardingStep.GOALS); + Analytics.track(AnalyticsEventType.TOUR_GOALS_SET); ApplicationManager.getApplication() .invokeLater( () -> { @@ -165,21 +154,20 @@ private static void showGoalsStep(Project project, OnboardingService service) { NotificationAction.createSimpleExpiring( "Maybe later", () -> { - trackAction("skip_goals", OnboardingStep.GOALS); + Analytics.track(AnalyticsEventType.TOUR_GOALS_LATER); service.nextStep(); })); notification.addAction( NotificationAction.createSimpleExpiring( "Skip tour", () -> { - trackAction("skip_tour", OnboardingStep.GOALS); + Analytics.track(AnalyticsEventType.TOUR_GOALS_SKIP); service.skipOnboarding(); })); notification.notify(project); } - /** Step 5: Optional Hub connection. */ private static void showHubConnectionStep(Project project, OnboardingService service) { Notification notification = NotificationGroupManager.getInstance() @@ -198,7 +186,7 @@ private static void showHubConnectionStep(Project project, OnboardingService ser NotificationAction.createSimpleExpiring( "Connect to Hub", () -> { - trackAction("connect_hub", OnboardingStep.HUB_CONNECTION); + Analytics.track(AnalyticsEventType.TOUR_HUB_CONNECT); ApplicationManager.getApplication() .invokeLater( () -> { @@ -210,14 +198,13 @@ private static void showHubConnectionStep(Project project, OnboardingService ser NotificationAction.createSimpleExpiring( "Skip, finish tour", () -> { - trackAction("skip_hub", OnboardingStep.HUB_CONNECTION); + Analytics.track(AnalyticsEventType.TOUR_HUB_SKIP); service.completeOnboarding(); })); notification.notify(project); } - /** Show completion notification. */ public static void showCompletionNotification(Project project) { Notification notification = NotificationGroupManager.getInstance() diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java index 77847af..1e89cb9 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java @@ -8,33 +8,39 @@ import static org.apache.commons.lang3.StringUtils.isBlank; import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle; -import com.codeclocker.plugin.intellij.config.Config; import com.codeclocker.plugin.intellij.config.ConfigProvider; +import com.codeclocker.plugin.intellij.local.BranchActivityRecord; +import com.codeclocker.plugin.intellij.local.CommitRecord; import com.codeclocker.plugin.intellij.local.LocalStateRepository; import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; +import com.codeclocker.plugin.intellij.services.BranchActivityTracker; import com.codeclocker.plugin.intellij.services.ChangesSample; +import com.codeclocker.plugin.intellij.services.CommitActivityTracker; import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger; -import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectSample; +import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.ProjectTimeDelta; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; -import com.codeclocker.plugin.intellij.subscription.CheckSubscriptionStateHttpClient; import com.fasterxml.jackson.core.JsonProcessingException; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; import com.intellij.openapi.diagnostic.Logger; import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Queue; import java.util.concurrent.ScheduledFuture; +@Service public final class DataReportingTask implements Disposable { - private static final Logger LOG = Logger.getInstance(CheckSubscriptionStateHttpClient.class); + private static final Logger LOG = Logger.getInstance(DataReportingTask.class); private static final DateTimeFormatter DATETIME_HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); @@ -42,6 +48,8 @@ public final class DataReportingTask implements Disposable { private final int flushToServerFrequencySeconds; private final TimeSpentPerProjectLogger timeSpentPerProjectLogger; private final ChangesActivityTracker changesActivityTracker; + private final BranchActivityTracker branchActivityTracker; + private final CommitActivityTracker commitActivityTracker; private final ActivitySampleHttpClient activitySampleHttpClient; private final LocalStateRepository localStateRepository; private final Queue unpublishedTimeSpentSamples = new ArrayDeque<>(); @@ -54,6 +62,10 @@ public DataReportingTask() { ApplicationManager.getApplication().getService(ChangesActivityTracker.class); this.timeSpentPerProjectLogger = ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + this.branchActivityTracker = + ApplicationManager.getApplication().getService(BranchActivityTracker.class); + this.commitActivityTracker = + ApplicationManager.getApplication().getService(CommitActivityTracker.class); this.activitySampleHttpClient = ApplicationManager.getApplication().getService(ActivitySampleHttpClient.class); ConfigProvider configProvider = @@ -78,45 +90,46 @@ public void schedule() { public void flushActivityData() { try { - String apiKey = ApiKeyLifecycle.getActiveApiKey(); - // Cleanup old local data periodically localStateRepository.rotate(); - Map timeSamples = timeSpentPerProjectLogger.drain(); + // Get deltas - data stays in accumulators, just marks as reported + Map timeDeltas = timeSpentPerProjectLogger.getProjectDeltas(); Map> changesSamples = changesActivityTracker.drain(); - if (timeSamples.isEmpty() && changesSamples.isEmpty()) { + + if (timeDeltas.isEmpty() && changesSamples.isEmpty()) { LOG.debug("No activity data to save locally"); return; } - saveToLocalStorage(timeSamples, changesSamples); + saveToLocalStorage(timeDeltas, changesSamples); // If API key is available, try to sync to server + String apiKey = ApiKeyLifecycle.getActiveApiKey(); if (!isBlank(apiKey)) { - sendActivitySampleToServer(apiKey, timeSamples, changesSamples); + sendActivitySampleToServer(apiKey, timeDeltas, changesSamples); } } catch (Exception ex) { LOG.debug("Error flushing activity data: {}", ex.getMessage()); } } - public void saveToLocalStorageIfApiKeyIsEmpty() { // todo: store in any case + public void saveToLocalStorageIfApiKeyIsEmpty() { String apiKey = ApiKeyLifecycle.getActiveApiKey(); if (isBlank(apiKey)) { - Map timeSamples = timeSpentPerProjectLogger.drain(); + Map timeDeltas = timeSpentPerProjectLogger.getProjectDeltas(); Map> changesSamples = changesActivityTracker.drain(); - if (timeSamples.isEmpty() && changesSamples.isEmpty()) { + if (timeDeltas.isEmpty() && changesSamples.isEmpty()) { LOG.debug("No activity data to save locally"); return; } - saveToLocalStorage(timeSamples, changesSamples); + saveToLocalStorage(timeDeltas, changesSamples); } } public void saveToLocalStorage( - Map timeSamples, + Map timeDeltas, Map> changesSamples) { // Aggregate VCS changes per project @@ -137,21 +150,43 @@ public void saveToLocalStorage( projectRemovals.put(projectName, totalRemovals); } - // Save time spent per project - for (Entry entry : timeSamples.entrySet()) { + String currentHourKey = + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH")); + + // Save time spent per project (using delta seconds) + for (Entry entry : timeDeltas.entrySet()) { String projectName = entry.getKey(); - long timeSeconds = entry.getValue().timeSpent().getSeconds(); + long deltaSeconds = entry.getValue().deltaSeconds(); long additions = projectAdditions.getOrDefault(projectName, 0L); long removals = projectRemovals.getOrDefault(projectName, 0L); ProjectActivitySnapshot snapshot = - new ProjectActivitySnapshot(timeSeconds, additions, removals, false); + new ProjectActivitySnapshot(deltaSeconds, additions, removals, false); + + // Add branch activity + if (branchActivityTracker != null) { + Map branchActivity = + branchActivityTracker.drainBranchActivity(projectName, currentHourKey); + List branchRecords = new ArrayList<>(); + for (Entry branchEntry : branchActivity.entrySet()) { + branchRecords.add(new BranchActivityRecord(branchEntry.getKey(), branchEntry.getValue())); + } + snapshot.setBranchActivity(branchRecords); + } + + // Add commits + if (commitActivityTracker != null) { + List commits = + commitActivityTracker.drainCommits(projectName, currentHourKey); + snapshot.setCommits(commits); + } + localStateRepository.mergeProjectCurrentHour(projectName, snapshot); } // Save VCS changes for projects without time entries for (String projectName : projectAdditions.keySet()) { - if (!timeSamples.containsKey(projectName)) { + if (!timeDeltas.containsKey(projectName)) { long additions = projectAdditions.get(projectName); long removals = projectRemovals.get(projectName); @@ -161,15 +196,13 @@ public void saveToLocalStorage( } } - LOG.debug("Saved activity data to local storage for " + timeSamples.size() + " projects"); + LOG.debug("Saved activity data to local storage for " + timeDeltas.size() + " projects"); } private void sendActivitySampleToServer( String apiKey, - Map timeSamples, + Map timeDeltas, Map> changesSamples) { - // Validate timers before flushing to detect inconsistencies - validateTimersBeforeFlush(); // First, sync any locally stored data to the server syncLocalDataToServer(apiKey); @@ -180,7 +213,7 @@ private void sendActivitySampleToServer( return; } - publishTimeSpentSample(apiKey, timeSamples); + publishTimeSpentSample(apiKey, timeDeltas); publishChangesSample(apiKey, changesSamples); } @@ -198,34 +231,36 @@ public void syncLocalDataToServer(String apiKey) { return; } - // Convert local data to DTOs and send - // Group by project across all dates for time spent - Map timeSpentByProject = new HashMap<>(); - // For changes, we send per-project aggregated data + // Send data hour by hour to preserve time distribution + // Map: hourKey -> (projectName -> TimeSpentSampleDto) + Map> timeSpentByHour = new HashMap<>(); + // For changes, we send per-project with hour info in filename Map> changesByProject = new HashMap<>(); for (Entry> hourEntry : localData.entrySet()) { - String datetimeHourStr = hourEntry.getKey(); - long samplingStartedAt = datetimeHourToTimestamp(datetimeHourStr); + String hourKey = hourEntry.getKey(); + long samplingStartedAt = datetimeHourToTimestamp(hourKey); for (Entry projectEntry : hourEntry.getValue().entrySet()) { String projectName = projectEntry.getKey(); ProjectActivitySnapshot snapshot = projectEntry.getValue(); - // Aggregate time spent per project + // Keep time spent data per hour per project (hourKey is already in UTC) if (snapshot.getCodedTimeSeconds() > 0) { - timeSpentByProject.merge( - projectName, - new TimeSpentSampleDto(samplingStartedAt, snapshot.getCodedTimeSeconds()), - (existing, incoming) -> + timeSpentByHour + .computeIfAbsent(hourKey, k -> new HashMap<>()) + .put( + projectName, new TimeSpentSampleDto( - Math.min(existing.samplingStartedAt(), incoming.samplingStartedAt()), - existing.timeSpentSeconds() + incoming.timeSpentSeconds())); + snapshot.getRecordId(), + hourKey, + snapshot.getCodedTimeSeconds(), + snapshot.getCodedTimeSeconds())); } - // Aggregate VCS changes per project + // Aggregate VCS changes per project (with hour in filename for tracking) if (snapshot.getAdditions() > 0 || snapshot.getRemovals() > 0) { - String syntheticFileName = "local-sync-" + datetimeHourStr; + String syntheticFileName = "local-sync-" + hourKey; ChangesSampleDto changesDto = new ChangesSampleDto( samplingStartedAt, @@ -240,17 +275,27 @@ public void syncLocalDataToServer(String apiKey) { } } - // Send time spent data + // Send time spent data hour by hour boolean timeSyncSuccess = true; - if (!timeSpentByProject.isEmpty()) { - String timeJson = toJson(timeSpentByProject); + int totalProjectsSynced = 0; + for (Entry> hourEntry : timeSpentByHour.entrySet()) { + Map projectsForHour = hourEntry.getValue(); + String timeJson = toJson(projectsForHour); SentStatus status = activitySampleHttpClient.sendTimeSpentSample(apiKey, timeJson); if (status == ERROR) { - LOG.warn("Failed to sync local time spent data to server, will retry later"); + LOG.warn("Failed to sync local time spent data for hour " + hourEntry.getKey()); timeSyncSuccess = false; - } else { - LOG.info("Synced local time spent data for " + timeSpentByProject.size() + " projects"); + break; } + totalProjectsSynced += projectsForHour.size(); + } + if (timeSyncSuccess && totalProjectsSynced > 0) { + LOG.info( + "Synced local time spent data: " + + timeSpentByHour.size() + + " hours, " + + totalProjectsSynced + + " project entries"); } // Send changes data @@ -283,28 +328,12 @@ private static long datetimeHourToTimestamp(String datetimeHourStr) { } } - private void validateTimersBeforeFlush() { - if (!Config.isValidateTimersEnabled()) { + private void publishTimeSpentSample(String apiKey, Map deltas) { + if (deltas.isEmpty()) { return; } - try { - TimeSpentPerProjectLogger.ValidationResult result = - timeSpentPerProjectLogger.validateTimers(); - - if (!result.isValid()) { - LOG.warn("Timer validation failed before flush: " + result.getSummary()); - } else { - LOG.debug("Timer validation passed: " + result.getSummary()); - } - } catch (Exception ex) { - LOG.warn("Error during timer validation", ex); - } - } - - private void publishTimeSpentSample( - String apiKey, Map sample) { - Map dto = toTimeSpentDto(sample); + Map dto = toTimeSpentDto(deltas); String json = toJson(dto); SentStatus status = activitySampleHttpClient.sendTimeSpentSample(apiKey, json); @@ -315,6 +344,10 @@ private void publishTimeSpentSample( } private void publishChangesSample(String apiKey, Map> sample) { + if (sample.isEmpty()) { + return; + } + Map> dto = toChangesDto(sample); String json = toJson(dto); @@ -377,13 +410,17 @@ private static Map> toChangesDto( } private static Map toTimeSpentDto( - Map activity) { + Map deltas) { Map sampleByProjectName = new HashMap<>(); - for (Entry entry : activity.entrySet()) { - TimeSpentPerProjectSample sample = entry.getValue(); + for (Entry entry : deltas.entrySet()) { + ProjectTimeDelta delta = entry.getValue(); + // hourKey is already in UTC (from ProjectTimeAccumulator) + // recordId is null here - live deltas use traditional ADD behavior on Hub + // Local storage sync uses recordId for idempotent REPLACE behavior TimeSpentSampleDto dto = - new TimeSpentSampleDto(sample.samplingStartedAt(), sample.timeSpent().getSeconds()); + new TimeSpentSampleDto( + null, delta.hourKey(), delta.deltaSeconds(), delta.totalHourSeconds()); sampleByProjectName.put(entry.getKey(), dto); } diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java index 9372154..f672b56 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java @@ -9,6 +9,7 @@ import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimePeriodComparisonDto; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; import com.intellij.openapi.diagnostic.Logger; import java.time.DayOfWeek; import java.time.Instant; @@ -19,6 +20,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicReference; +@Service public final class TimeComparisonFetchTask implements Disposable { private static final Logger LOG = Logger.getInstance(TimeComparisonFetchTask.class); diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java index 6b64032..f734be1 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java @@ -1,3 +1,13 @@ package com.codeclocker.plugin.intellij.reporting; -public record TimeSpentSampleDto(long samplingStartedAt, long timeSpentSeconds) {} +/** + * DTO for time spent sample sent to the hub. + * + * @param recordId unique identifier for this record (for idempotent sync), nullable for backward + * compatibility + * @param hourKey hour bucket in format "yyyy-MM-dd-HH" in UTC timezone (e.g., "2025-12-28-10") + * @param deltaSeconds seconds accumulated since last report (increment) + * @param totalHourSeconds total seconds for this hour bucket (for verification) + */ +public record TimeSpentSampleDto( + String recordId, String hourKey, long deltaSeconds, long totalHourSeconds) {} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java b/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java new file mode 100644 index 0000000..9fc5b1e --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java @@ -0,0 +1,171 @@ +package com.codeclocker.plugin.intellij.services; + +import com.intellij.openapi.components.Service; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.project.Project; +import git4idea.repo.GitRepository; +import git4idea.repo.GitRepositoryManager; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Tracks git branch activity per project. Records which branch is active and accumulates time spent + * on each branch per hour bucket. + */ +@Service(Service.Level.APP) +public final class BranchActivityTracker { + + private static final Logger LOG = Logger.getInstance(BranchActivityTracker.class); + private static final DateTimeFormatter HOUR_KEY_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + private static final long MILLIS_PER_SECOND = 1000L; + + /** Current branch per project. */ + private final Map currentBranchByProject = new ConcurrentHashMap<>(); + + /** Branch activity: project -> hour -> branch -> seconds. */ + private final Map>> branchActivityByProject = + new ConcurrentHashMap<>(); + + /** Last activity tick timestamp per project for calculating elapsed time. */ + private final Map lastTickTimestampByProject = new ConcurrentHashMap<>(); + + /** Whether tracking is active per project. */ + private final Map activeByProject = new ConcurrentHashMap<>(); + + /** + * Called when branch changes in a project. + * + * @param projectName the project name + * @param newBranch the new branch name (or "detached" if in detached HEAD state) + */ + public void onBranchChange(String projectName, String newBranch) { + String previousBranch = currentBranchByProject.put(projectName, newBranch); + if (previousBranch != null && !previousBranch.equals(newBranch)) { + LOG.info("Branch changed in " + projectName + ": " + previousBranch + " -> " + newBranch); + } + } + + /** + * Records a tick of activity on the current branch. Called when TimeSpentPerProjectLogger.log() + * is invoked. Calculates elapsed time since last tick. + * + * @param projectName the project name + */ + public void recordActivityTick(String projectName) { + String branch = currentBranchByProject.get(projectName); + if (branch == null) { + return; + } + + long now = System.currentTimeMillis(); + Long lastTick = lastTickTimestampByProject.get(projectName); + Boolean wasActive = activeByProject.get(projectName); + + // Update tracking state + lastTickTimestampByProject.put(projectName, now); + activeByProject.put(projectName, true); + + // Calculate elapsed if we were actively tracking + if (lastTick != null && Boolean.TRUE.equals(wasActive)) { + long elapsedMillis = now - lastTick; + long elapsedSeconds = Math.round((float) elapsedMillis / MILLIS_PER_SECOND); + + if (elapsedSeconds > 0 && elapsedSeconds < 300) { // Cap at 5 minutes to avoid huge gaps + String hourKey = LocalDateTime.now().format(HOUR_KEY_FORMATTER); + branchActivityByProject + .computeIfAbsent(projectName, k -> new ConcurrentHashMap<>()) + .computeIfAbsent(hourKey, k -> new ConcurrentHashMap<>()) + .merge(branch, elapsedSeconds, Long::sum); + } + } + } + + /** Mark project as inactive (due to inactivity timeout). */ + public void pauseProject(String projectName) { + activeByProject.put(projectName, false); + } + + /** + * Get current branch for a project. + * + * @param projectName the project name + * @return the current branch name, or null if not tracked + */ + public String getCurrentBranch(String projectName) { + return currentBranchByProject.get(projectName); + } + + /** + * Drain and return branch activity for a project/hour, clearing the data. + * + * @param projectName the project name + * @param hourKey the hour key + * @return map of branch name to seconds, or empty map if no data + */ + public Map drainBranchActivity(String projectName, String hourKey) { + Map> projectActivity = branchActivityByProject.get(projectName); + if (projectActivity == null) { + return new HashMap<>(); + } + + Map hourActivity = projectActivity.remove(hourKey); + return hourActivity != null ? new HashMap<>(hourActivity) : new HashMap<>(); + } + + /** + * Drain all branch activity for a project across all hours. + * + * @param projectName the project name + * @return map of hourKey to (branch -> seconds) + */ + public Map> drainAllBranchActivity(String projectName) { + Map> projectActivity = branchActivityByProject.remove(projectName); + if (projectActivity == null) { + return new HashMap<>(); + } + return new HashMap<>(projectActivity); + } + + /** + * Initialize branch tracking for a project by reading current branch from git. + * + * @param project the IntelliJ project + */ + public void initializeFromGit(Project project) { + if (project == null || project.isDisposed()) { + return; + } + + try { + GitRepositoryManager gitManager = GitRepositoryManager.getInstance(project); + if (gitManager == null) { + return; + } + + for (GitRepository repo : gitManager.getRepositories()) { + String branchName = getBranchName(repo); + currentBranchByProject.put(project.getName(), branchName); + LOG.info("Initialized branch tracking for " + project.getName() + ": " + branchName); + break; // Use first repository for now + } + } catch (Exception e) { + LOG.warn("Failed to initialize branch tracking for " + project.getName(), e); + } + } + + private String getBranchName(GitRepository repo) { + if (repo.getCurrentBranch() != null) { + return repo.getCurrentBranch().getName(); + } + // Detached HEAD state - show short hash + String revision = repo.getCurrentRevision(); + if (revision != null && revision.length() > 7) { + return "detached:" + revision.substring(0, 7); + } + return "detached"; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/CodingTimeCalculator.java b/src/main/java/com/codeclocker/plugin/intellij/services/CodingTimeCalculator.java new file mode 100644 index 0000000..57f7a99 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/services/CodingTimeCalculator.java @@ -0,0 +1,52 @@ +package com.codeclocker.plugin.intellij.services; + +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; + +/** + * Calculates total coding time by combining persisted data from LocalActivityDataProvider with + * current unsaved delta from accumulators. This is the single source of truth for displaying coding + * time. + */ +@Service(Service.Level.APP) +public final class CodingTimeCalculator { + + /** + * Get total coded seconds for today across all projects. Combines persisted data from + * LocalActivityDataProvider (in local timezone) with current unsaved delta from accumulators. + * + * @return total accumulated seconds today + */ + public long getTodayTotalSeconds() { + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + + long persistedSeconds = dataProvider != null ? dataProvider.getTodayTotalSeconds() : 0; + long unsavedDelta = logger != null ? logger.getGlobalUnsavedDelta() : 0; + + return persistedSeconds + unsavedDelta; + } + + /** + * Get total coded seconds for today for a specific project. Combines persisted data from + * LocalActivityDataProvider (in local timezone) with current unsaved delta from accumulator. + * + * @param projectName the project name + * @return accumulated seconds for this project today + */ + public long getTodayProjectSeconds(String projectName) { + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + + long persistedSeconds = + dataProvider != null ? dataProvider.getTodayProjectSeconds(projectName) : 0; + long unsavedDelta = logger != null ? logger.getProjectUnsavedDelta(projectName) : 0; + + return persistedSeconds + unsavedDelta; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/CommitActivityTracker.java b/src/main/java/com/codeclocker/plugin/intellij/services/CommitActivityTracker.java new file mode 100644 index 0000000..b05ca5b --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/services/CommitActivityTracker.java @@ -0,0 +1,83 @@ +package com.codeclocker.plugin.intellij.services; + +import com.codeclocker.plugin.intellij.local.CommitRecord; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.diagnostic.Logger; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Tracks commit activity per project and hour bucket. Stores commit records that can be drained + * during periodic data flush. + */ +@Service(Service.Level.APP) +public final class CommitActivityTracker { + + private static final Logger LOG = Logger.getInstance(CommitActivityTracker.class); + private static final DateTimeFormatter HOUR_KEY_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + + /** Commits by project and hour: project -> hour -> commits list. */ + private final Map>> commitsByProject = + new ConcurrentHashMap<>(); + + /** + * Record a new commit for a project. + * + * @param projectName the project name + * @param commit the commit record + */ + public void recordCommit(String projectName, CommitRecord commit) { + String hourKey = LocalDateTime.now().format(HOUR_KEY_FORMATTER); + + commitsByProject + .computeIfAbsent(projectName, k -> new ConcurrentHashMap<>()) + .computeIfAbsent(hourKey, k -> new CopyOnWriteArrayList<>()) + .add(commit); + + LOG.info( + "Recorded commit " + + commit.getHash() + + " for project " + + projectName + + " in hour " + + hourKey); + } + + /** + * Drain and return commits for a project/hour, clearing the data. + * + * @param projectName the project name + * @param hourKey the hour key + * @return list of commits, or empty list if no data + */ + public List drainCommits(String projectName, String hourKey) { + Map> projectCommits = commitsByProject.get(projectName); + if (projectCommits == null) { + return new ArrayList<>(); + } + + List hourCommits = projectCommits.remove(hourKey); + return hourCommits != null ? new ArrayList<>(hourCommits) : new ArrayList<>(); + } + + /** + * Drain all commits for a project across all hours. + * + * @param projectName the project name + * @return map of hourKey to commits list + */ + public Map> drainAllCommits(String projectName) { + Map> projectCommits = commitsByProject.remove(projectName); + if (projectCommits == null) { + return new HashMap<>(); + } + return new HashMap<>(projectCommits); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/ProjectTimeAccumulator.java b/src/main/java/com/codeclocker/plugin/intellij/services/ProjectTimeAccumulator.java new file mode 100644 index 0000000..24faf88 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/services/ProjectTimeAccumulator.java @@ -0,0 +1,124 @@ +package com.codeclocker.plugin.intellij.services; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import org.jetbrains.annotations.Nullable; + +/** + * Accumulates coding time for a single project within an hour bucket. Thread-safe via synchronized + * methods and volatile fields. Hour keys are stored in UTC timezone. + */ +public class ProjectTimeAccumulator { + + public static final DateTimeFormatter HOUR_KEY_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + private static final ZoneId UTC = ZoneId.of("UTC"); + + private static final long MILLIS_PER_SECOND = 1000L; + + private volatile String hourKey; + private volatile long accumulatedSeconds; + private volatile long lastActivityTimestampMillis; + private volatile boolean active; + private volatile long lastReportedSeconds; + + public ProjectTimeAccumulator() { + this.hourKey = ZonedDateTime.now(UTC).format(HOUR_KEY_FORMATTER); + this.accumulatedSeconds = 0; + this.lastActivityTimestampMillis = 0; + this.active = false; + this.lastReportedSeconds = 0; + } + + public synchronized void activate(long timestampMillis) { + this.active = true; + this.lastActivityTimestampMillis = timestampMillis; + } + + public synchronized void deactivate() { + this.active = false; + } + + public synchronized void calculateAndAddElapsed(long now) { + if (!active || lastActivityTimestampMillis == 0) { + return; + } + + long elapsedMillis = now - lastActivityTimestampMillis; + long elapsedSeconds = Math.round((float) elapsedMillis / MILLIS_PER_SECOND); + if (elapsedSeconds > 0) { + this.accumulatedSeconds += elapsedSeconds; + } + } + + /** + * Get the delta (unreported seconds) and mark them as reported. + * + * @return seconds accumulated since last report + */ + public synchronized long getUnreportedDeltaAndMarkReported() { + long delta = accumulatedSeconds - lastReportedSeconds; + this.lastReportedSeconds = accumulatedSeconds; + + return delta; + } + + /** + * Check if hour has changed and finalize old hour data if so. + * + * @return HourTransition with old hour data if hour changed, null otherwise + */ + @Nullable + public synchronized HourTransition checkAndHandleHourBoundary() { + String currentHour = ZonedDateTime.now(UTC).format(HOUR_KEY_FORMATTER); + if (!currentHour.equals(hourKey)) { + HourTransition transition = + new HourTransition(hourKey, accumulatedSeconds, lastReportedSeconds); + + // Reset for new hour + hourKey = currentHour; + accumulatedSeconds = 0; + lastReportedSeconds = 0; + lastActivityTimestampMillis = 0; + + return transition; + } + + return null; + } + + public String getHourKey() { + return hourKey; + } + + public long getAccumulatedSeconds() { + return accumulatedSeconds; + } + + /** + * Get the current unsaved delta (time accumulated since last report/flush). Does NOT mark as + * reported - use this for display purposes only. + * + * @return seconds accumulated since last report + */ + public long getUnsavedDelta() { + return accumulatedSeconds - lastReportedSeconds; + } + + public synchronized void setAccumulatedSeconds(long seconds) { + this.accumulatedSeconds = seconds; + } + + /** Result of hour boundary check containing data for the finalized hour. */ + public record HourTransition(String hourKey, long accumulatedSeconds, long lastReportedSeconds) { + + public long getDelta() { + return accumulatedSeconds - lastReportedSeconds; + } + + public boolean hasUnreportedSeconds() { + return getDelta() > 0; + } + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentActivityTracker.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentActivityTracker.java index c243786..8ad93f4 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentActivityTracker.java +++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentActivityTracker.java @@ -3,6 +3,7 @@ import static com.codeclocker.plugin.intellij.ScheduledExecutor.EXECUTOR; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import com.codeclocker.plugin.intellij.tracking.TrackingPersistence; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.project.Project; @@ -14,7 +15,6 @@ public class TimeSpentActivityTracker implements Disposable { private final TimeSpentPerProjectLogger timeSpentPerProjectLogger; - private final long pauseActivityAfterInactivityMillis = Duration.ofMinutes(2).toMillis(); private final AtomicReference> scheduledTask; private final AtomicLong lastRescheduledAt = new AtomicLong(); @@ -24,6 +24,10 @@ public TimeSpentActivityTracker() { ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); } + private long getInactivityTimeoutMillis() { + return Duration.ofSeconds(TrackingPersistence.getInactivityTimeoutSeconds()).toMillis(); + } + public void logTime(Project project) { rescheduleInactivityTask(); timeSpentPerProjectLogger.log(project); @@ -45,7 +49,7 @@ public void rescheduleInactivityTask() { } private ScheduledFuture schedule() { - return EXECUTOR.schedule(this::pause, pauseActivityAfterInactivityMillis, MILLISECONDS); + return EXECUTOR.schedule(this::pause, getInactivityTimeoutMillis(), MILLISECONDS); } public void pause() { diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java index 06264da..c7a3d7f 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java +++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java @@ -1,225 +1,258 @@ package com.codeclocker.plugin.intellij.services; -import com.codeclocker.plugin.intellij.stopwatch.SafeStopWatch; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; +/** + * Tracks coding time per project using accumulated seconds approach. Replaces the previous + * stopwatch-based implementation with a simpler model that: + * + *

    + *
  • Tracks time per project in hour buckets + *
  • Supports delta-based reporting to the hub + *
  • Handles hour boundaries and midnight resets + *
+ */ public class TimeSpentPerProjectLogger { private static final Logger LOG = Logger.getInstance(TimeSpentPerProjectLogger.class); - public static final SafeStopWatch GLOBAL_STOP_WATCH = SafeStopWatch.createStopped(); - public static final AtomicLong GLOBAL_INIT_SECONDS = new AtomicLong(); + /** Per-project time accumulators - the single source of truth for all time data. */ + private final Map accumulatorsByProject = + new ConcurrentHashMap<>(); - private final Map timingByProject = new ConcurrentHashMap<>(); - private final AtomicReference currentProject = new AtomicReference<>(); - private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); + /** Currently active project. */ + private final AtomicReference currentProjectName = new AtomicReference<>(); + /** Track last date for midnight reset. */ + private volatile LocalDate lastDate = LocalDate.now(); + + /** Hour transitions that occurred and need to be reported. */ + private final List pendingHourTransitions = + Collections.synchronizedList(new ArrayList<>()); + + /** + * Called when user activity is detected in a project. Calculates elapsed time since last activity + * and adds to accumulator. + */ public void log(Project project) { - GLOBAL_STOP_WATCH.resume(); - Project prevProject = this.currentProject.getAndSet(project); + if (project == null || project.isDisposed()) { + return; + } + + String projectName = project.getName(); + long now = System.currentTimeMillis(); - if (prevProject != null && !Objects.equals(prevProject.getName(), project.getName())) { - pauseWatchForPrevProject(prevProject); + deactivatePrevProject(projectName, now); + + ProjectTimeAccumulator accumulator = + accumulatorsByProject.computeIfAbsent(projectName, k -> new ProjectTimeAccumulator()); + + checkHourBoundary(accumulator, projectName); + + accumulator.calculateAndAddElapsed(now); + accumulator.activate(now); + + // Record branch activity + BranchActivityTracker branchTracker = + ApplicationManager.getApplication().getService(BranchActivityTracker.class); + if (branchTracker != null) { + branchTracker.recordActivityTick(projectName); } + } - Lock lock = readWriteLock.readLock(); - try { - lock.lock(); - timingByProject.compute( - project.getName(), - (name, sample) -> { - if (project.isDisposed()) { - return sample; - } - TimeTrackerWidgetService service = project.getService(TimeTrackerWidgetService.class); - service.resume(); - if (sample == null) { - return TimeSpentPerProjectSample.createStarted(); - } - return sample.resume(); - }); - } finally { - lock.unlock(); + private void checkHourBoundary(ProjectTimeAccumulator accumulator, String projectName) { + ProjectTimeAccumulator.HourTransition transition = accumulator.checkAndHandleHourBoundary(); + if (transition != null && transition.hasUnreportedSeconds()) { + synchronized (pendingHourTransitions) { + pendingHourTransitions.add(new HourTransitionRecord(projectName, transition)); + } + LOG.debug( + "Hour transition for {}: {} with {} unreported seconds", + projectName, + transition.hourKey(), + transition.getDelta()); } } - private void pauseWatchForPrevProject(Project prevProject) { - Lock lock = readWriteLock.readLock(); - try { - lock.lock(); - timingByProject.compute( - prevProject.getName(), - (name, sample) -> { - if (prevProject.isDisposed()) { - if (sample != null) { - sample.pause(); - } - return sample; - } - TimeTrackerWidgetService service = - prevProject.getService(TimeTrackerWidgetService.class); - service.pause(); - if (sample == null) { - return null; - } - - sample.pause(); - return sample; - }); - } finally { - lock.unlock(); + private void deactivatePrevProject(String currentProjectName, long now) { + String prevProjectName = this.currentProjectName.getAndSet(currentProjectName); + if (prevProjectName != null && !Objects.equals(prevProjectName, currentProjectName)) { + deactivateProject(prevProjectName, now); } } public void pauseDueToInactivity() { - GLOBAL_STOP_WATCH.pause(); - currentProject.updateAndGet( - currentProject -> { - if (currentProject == null) { - return null; - } - - Lock lock = readWriteLock.readLock(); - try { - lock.lock(); - timingByProject.compute( - currentProject.getName(), - (name, sample) -> { - if (currentProject.isDisposed()) { - if (sample != null) { - sample.pause(); - } - return sample; - } - TimeTrackerWidgetService service = - currentProject.getService(TimeTrackerWidgetService.class); - service.pause(); - if (sample != null) { - sample.pause(); - } - return sample; - }); - - return currentProject; - } finally { - lock.unlock(); - } - }); + String projectName = currentProjectName.get(); + if (projectName != null) { + deactivateProject(projectName, System.currentTimeMillis()); + } } - public void pauseProject(Project project) { - Lock lock = readWriteLock.readLock(); - try { - lock.lock(); - timingByProject.computeIfPresent( - project.getName(), - (name, sample) -> { - sample.pause(); - LOG.debug("Paused time tracking for closing project: " + name); - return sample; - }); - - clearCurrentProject(project); - } finally { - lock.unlock(); + public void closeProject(Project project) { + if (project == null) { + return; } + deactivateProject(project.getName(), System.currentTimeMillis()); + currentProjectName.updateAndGet( + current -> current != null && Objects.equals(current, project.getName()) ? null : current); } - private void clearCurrentProject(Project project) { - currentProject.updateAndGet( - current -> - current != null && Objects.equals(current.getName(), project.getName()) - ? null - : current); + private void deactivateProject(String projectName, long now) { + ProjectTimeAccumulator accumulator = accumulatorsByProject.get(projectName); + if (accumulator != null) { + accumulator.calculateAndAddElapsed(now); + accumulator.deactivate(); + } } - public Map drain() { - Lock lock = readWriteLock.writeLock(); - try { - lock.lock(); + /** + * Get deltas for all projects for reporting. Does NOT clear data - just marks what was reported. + * + * @return map of project name to delta info + */ + public Map getProjectDeltas() { + Map deltas = new HashMap<>(); - Map drain = new HashMap<>(timingByProject); - timingByProject.clear(); + // First, add any pending hour transitions + synchronized (pendingHourTransitions) { + for (HourTransitionRecord record : pendingHourTransitions) { + ProjectTimeAccumulator.HourTransition t = record.transition; + if (t.getDelta() > 0) { + deltas.merge( + record.projectName, + new ProjectTimeDelta(t.hourKey(), t.getDelta(), t.accumulatedSeconds()), + (existing, incoming) -> + new ProjectTimeDelta( + incoming.hourKey(), + existing.deltaSeconds() + incoming.deltaSeconds(), + incoming.totalHourSeconds())); + } + } + pendingHourTransitions.clear(); + } + + // Then add current hour deltas + for (Map.Entry entry : accumulatorsByProject.entrySet()) { + String projectName = entry.getKey(); + ProjectTimeAccumulator acc = entry.getValue(); - return drain; - } finally { - lock.unlock(); + long delta = acc.getUnreportedDeltaAndMarkReported(); + if (delta > 0) { + // Merge with any existing delta for this project (from hour transitions) + deltas.merge( + projectName, + new ProjectTimeDelta(acc.getHourKey(), delta, acc.getAccumulatedSeconds()), + (existing, incoming) -> + new ProjectTimeDelta( + incoming.hourKey(), + existing.deltaSeconds() + incoming.deltaSeconds(), + incoming.totalHourSeconds())); + } } + + return deltas; } /** - * Validates that the global stopwatch time matches the sum of all per-project times. This helps - * detect timing inconsistencies, data corruption, or race conditions. + * Get total seconds for today across all projects. Delegates to CodingTimeCalculator. * - * @return ValidationResult containing whether validation passed and details about any mismatch + * @return total accumulated seconds today */ - public ValidationResult validateTimers() { - Lock lock = readWriteLock.readLock(); - try { - lock.lock(); - - long globalTime = GLOBAL_STOP_WATCH.getSeconds(); - long sumOfProjects = - timingByProject.values().stream() - .mapToLong(sample -> sample.timeSpent().getSeconds()) - .sum(); - - long difference = Math.abs(globalTime - sumOfProjects); - - // Allow small differences (up to 2 seconds) due to timing precision and race conditions - boolean isValid = difference <= 2; - - if (!isValid) { - LOG.warn( - String.format( - "Timer mismatch detected! Global time: %ds, Sum of projects: %ds, Difference: %ds", - globalTime, sumOfProjects, difference)); - - // Log per-project breakdown for debugging - if (LOG.isDebugEnabled()) { - StringBuilder breakdown = new StringBuilder("Per-project timing breakdown:\n"); - timingByProject.forEach( - (projectName, sample) -> { - long projectSeconds = sample.timeSpent().getSeconds(); - breakdown.append( - String.format( - " - %s: %ds (started at %d)\n", - projectName, projectSeconds, sample.samplingStartedAt())); - }); - LOG.debug(breakdown.toString()); - } - } else if (difference > 0) { - LOG.debug( - String.format( - "Timer validation passed with minor difference: Global=%ds, Sum=%ds, Diff=%ds", - globalTime, sumOfProjects, difference)); - } + public long getGlobalAccumulatedToday() { + CodingTimeCalculator calculator = + ApplicationManager.getApplication().getService(CodingTimeCalculator.class); + return calculator != null ? calculator.getTodayTotalSeconds() : getGlobalUnsavedDelta(); + } - return new ValidationResult(isValid, globalTime, sumOfProjects, difference); + /** + * Get project-specific accumulated seconds for today. Delegates to CodingTimeCalculator. + * + * @param projectName the project name + * @return accumulated seconds for this project today + */ + public long getProjectAccumulatedToday(String projectName) { + CodingTimeCalculator calculator = + ApplicationManager.getApplication().getService(CodingTimeCalculator.class); + return calculator != null + ? calculator.getTodayProjectSeconds(projectName) + : getProjectUnsavedDelta(projectName); + } + + /** Get the current unsaved delta across all projects (time accumulated since last flush). */ + public long getGlobalUnsavedDelta() { + String todayPrefix = LocalDate.now().toString(); + return accumulatorsByProject.values().stream() + .filter(acc -> acc.getHourKey().startsWith(todayPrefix)) + .mapToLong(ProjectTimeAccumulator::getUnsavedDelta) + .sum(); + } - } finally { - lock.unlock(); + /** Get the current unsaved delta for a specific project (time accumulated since last flush). */ + public long getProjectUnsavedDelta(String projectName) { + ProjectTimeAccumulator acc = accumulatorsByProject.get(projectName); + if (acc != null) { + String todayPrefix = LocalDate.now().toString(); + if (acc.getHourKey().startsWith(todayPrefix)) { + return acc.getUnsavedDelta(); + } } + return 0; } - /** Result of timer validation containing timing details and whether validation passed. */ - public record ValidationResult( - boolean isValid, long globalTimeSeconds, long sumOfProjectsSeconds, long differenceSeconds) { + /** + * Initialize accumulators from local state on startup. + * + * @param projectSecondsToday map of project name to seconds accumulated today + */ + public void initializeFromLocalState(Map projectSecondsToday) { + for (Map.Entry entry : projectSecondsToday.entrySet()) { + String projectName = entry.getKey(); + long seconds = entry.getValue(); - public String getSummary() { - return String.format( - "Global: %ds, Sum: %ds, Diff: %ds, Valid: %s", - globalTimeSeconds, sumOfProjectsSeconds, differenceSeconds, isValid); + ProjectTimeAccumulator acc = new ProjectTimeAccumulator(); + acc.setAccumulatedSeconds(seconds); + // Mark as already reported since this is loaded from persistent state + acc.getUnreportedDeltaAndMarkReported(); + accumulatorsByProject.put(projectName, acc); } + + LOG.info( + "Initialized time tracking from local state: " + + projectSecondsToday.size() + + " projects, " + + getGlobalAccumulatedToday() + + "s total"); } + + /** + * Mark new day (called at midnight by widget service). Does not clear accumulators - getters + * filter by today's date instead to avoid data loss. + */ + public void resetForNewDay() { + LOG.info("New day detected, updating date marker"); + lastDate = LocalDate.now(); + } + + /** Check if midnight has passed (for external callers like widget service). */ + public boolean hasMidnightPassed() { + return !LocalDate.now().equals(lastDate); + } + + /** Delta information for a project to be reported to the hub. */ + public record ProjectTimeDelta(String hourKey, long deltaSeconds, long totalHourSeconds) {} + + /** Record of an hour transition for a specific project. */ + private record HourTransitionRecord( + String projectName, ProjectTimeAccumulator.HourTransition transition) {} } diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java deleted file mode 100644 index 7925b76..0000000 --- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.codeclocker.plugin.intellij.services; - -import com.codeclocker.plugin.intellij.stopwatch.SafeStopWatch; - -public record TimeSpentPerProjectSample(long samplingStartedAt, SafeStopWatch timeSpent) { - - public static TimeSpentPerProjectSample createStarted() { - return new TimeSpentPerProjectSample(System.currentTimeMillis(), SafeStopWatch.createStarted()); - } - - public TimeSpentPerProjectSample resume() { - timeSpent.resume(); - - return this; - } - - public void pause() { - timeSpent.pause(); - } - - public boolean isRunning() { - return timeSpent.isRunning(); - } -} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java index 1a16eb1..5ca4d01 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java +++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java @@ -1,29 +1,24 @@ package com.codeclocker.plugin.intellij.services; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_INIT_SECONDS; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_STOP_WATCH; import static com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker.GLOBAL_ADDITIONS; import static com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker.GLOBAL_REMOVALS; import com.codeclocker.plugin.intellij.goal.GoalNotificationService; -import com.codeclocker.plugin.intellij.local.LocalStateRepository; -import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; -import com.codeclocker.plugin.intellij.stopwatch.SafeStopWatch; import com.codeclocker.plugin.intellij.widget.TimeTrackerWidget; import com.intellij.openapi.Disposable; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; import com.intellij.util.concurrency.AppExecutorUtil; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Map; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; +/** + * Per-project service that manages the time tracker widget display. Reads time data from + * TimeSpentPerProjectLogger (the single source of truth). + */ public class TimeTrackerWidgetService implements Disposable { private static final Logger LOG = Logger.getInstance(TimeTrackerWidgetService.class); @@ -32,84 +27,38 @@ public class TimeTrackerWidgetService implements Disposable { private final Project project; private final TimeTrackerWidget widget; - private final AtomicLong initProjectTime = new AtomicLong(0); - private final SafeStopWatch projectStopWatch = SafeStopWatch.createStopped(); + private final TimeSpentPerProjectLogger logger; - private LocalDate lastDate = LocalDate.now(); private ScheduledFuture ticker; public TimeTrackerWidgetService(Project project) { this.project = project; this.widget = new TimeTrackerWidget(project, this); - - // Initialize project time from local state for late-opened projects - initializeProjectTimeFromLocalState(); + this.logger = ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); startTicker(); // Force an initial repaint to ensure the widget shows current data - // This handles cases where the project is opened after the global initialization ApplicationManager.getApplication().invokeLater(this::repaintWidget); } /** - * Initialize project-specific time from local state. This ensures projects opened after the - * global initialization still get their correct per-project time. + * Get total seconds for today across all projects. Reads directly from the logger which is the + * single source of truth. */ - private void initializeProjectTimeFromLocalState() { - try { - LocalStateRepository localState = - ApplicationManager.getApplication().getService(LocalStateRepository.class); - if (localState == null) { - return; - } - - String todayPrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); - String projectName = project.getName(); - long totalProjectSeconds = 0; - - for (Map.Entry> hourEntry : - localState.getAllData().entrySet()) { - if (!hourEntry.getKey().startsWith(todayPrefix)) { - continue; - } - - ProjectActivitySnapshot snapshot = hourEntry.getValue().get(projectName); - if (snapshot != null) { - totalProjectSeconds += snapshot.getCodedTimeSeconds(); - } - } - - if (totalProjectSeconds > 0) { - LOG.debug( - "Initialized project {} with {}s from local state", projectName, totalProjectSeconds); - this.initProjectTime.set(totalProjectSeconds); - } - } catch (Exception e) { - LOG.warn("Failed to initialize project time from local state", e); - } - } - - public void initialize(long initialSeconds) { - this.initProjectTime.set(initialSeconds); - this.projectStopWatch.reset(); - repaintWidget(); - } - - public void pause() { - projectStopWatch.pause(); - } - - public void resume() { - projectStopWatch.resume(); - } - public long getTotalSeconds() { - return GLOBAL_INIT_SECONDS.get() + GLOBAL_STOP_WATCH.getSeconds(); + if (logger == null) { + return 0; + } + return logger.getGlobalAccumulatedToday(); } + /** Get seconds for this specific project today. Reads directly from the logger. */ public long getProjectSeconds() { - return initProjectTime.get() + projectStopWatch.getSeconds(); + if (logger == null) { + return 0; + } + return logger.getProjectAccumulatedToday(project.getName()); } public String getFormattedProjectTime() { @@ -163,16 +112,12 @@ private void checkGoalNotifications() { } private void checkMidnightReset() { - LocalDate currentDate = LocalDate.now(); - if (!currentDate.equals(lastDate)) { + if (logger != null && logger.hasMidnightPassed()) { LOG.info("Midnight detected for project: " + project.getName()); - initProjectTime.set(0); - GLOBAL_INIT_SECONDS.set(0); - projectStopWatch.reset(); + // Logger handles its own reset, we just need to reset VCS counters GLOBAL_ADDITIONS.set(0); GLOBAL_REMOVALS.set(0); - GLOBAL_STOP_WATCH.reset(); // Reset per-project VCS changes counters ChangesActivityTracker changesTracker = @@ -181,7 +126,8 @@ private void checkMidnightReset() { changesTracker.clearAllProjectChanges(); } - lastDate = currentDate; + // Trigger the logger to reset (it checks internally) + logger.resetForNewDay(); } } diff --git a/src/main/java/com/codeclocker/plugin/intellij/stopwatch/SafeStopWatch.java b/src/main/java/com/codeclocker/plugin/intellij/stopwatch/SafeStopWatch.java deleted file mode 100644 index cb32d4d..0000000 --- a/src/main/java/com/codeclocker/plugin/intellij/stopwatch/SafeStopWatch.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.codeclocker.plugin.intellij.stopwatch; - -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; -import org.apache.commons.lang3.time.StopWatch; - -public class SafeStopWatch { - - private final AtomicReference stopWatch; - - public SafeStopWatch(StopWatch stopWatch) { - this.stopWatch = new AtomicReference<>(stopWatch); - } - - public static SafeStopWatch createStarted() { - return new SafeStopWatch(StopWatch.createStarted()); - } - - public static SafeStopWatch createStopped() { - return new SafeStopWatch(StopWatch.create()); - } - - public long getSeconds() { - return this.stopWatch.get().getTime(TimeUnit.SECONDS); - } - - public void resume() { - stopWatch.updateAndGet( - time -> { - if (!time.isStarted()) { - time.start(); - } else if (time.isSuspended()) { - time.resume(); - } - return time; - }); - } - - public void pause() { - stopWatch.updateAndGet( - time -> { - if (!time.isSuspended() && time.isStarted()) { - time.suspend(); - } - return time; - }); - } - - public void reset() { - stopWatch.set(StopWatch.create()); - } - - public boolean isRunning() { - return stopWatch.get().isStarted(); - } -} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeNode.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeNode.java new file mode 100644 index 0000000..f0f16b3 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeNode.java @@ -0,0 +1,174 @@ +package com.codeclocker.plugin.intellij.toolwindow; + +import com.codeclocker.plugin.intellij.local.CommitRecord; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import javax.swing.tree.DefaultMutableTreeNode; + +/** Tree node for activity data - can represent daily summary, hourly detail, or commit. */ +public class ActivityTreeNode extends DefaultMutableTreeNode { + + public enum NodeType { + DAILY, + HOURLY, + COMMIT + } + + private final NodeType nodeType; + private final String dateDisplay; + private final String hourDisplay; + private final String branchName; + private final long seconds; + private final String timeDisplay; + private final List commits; + private final String commitsDisplay; + private final String commitMessage; + + /** Creates a daily summary node (parent). */ + public static ActivityTreeNode createDailyNode( + String dateDisplay, long totalSeconds, List branches, List allCommits) { + String branchesDisplay = formatBranches(branches); + String timeDisplay = formatTime(totalSeconds); + String commitsDisplay = formatCommitsCount(allCommits); + return new ActivityTreeNode( + NodeType.DAILY, + dateDisplay, + null, + branchesDisplay, + totalSeconds, + timeDisplay, + allCommits, + commitsDisplay, + null); + } + + /** Creates an hourly detail node (child of daily). */ + public static ActivityTreeNode createHourlyNode(BranchActivityRow row) { + return new ActivityTreeNode( + NodeType.HOURLY, + null, + row.hourDisplay(), + row.branchName(), + row.seconds(), + row.timeDisplay(), + row.commits(), + row.commitsDisplay(), + null); + } + + /** Creates a commit node (child of hourly). */ + public static ActivityTreeNode createCommitNode(CommitRecord commit, String projectName) { + String message; + if (projectName != null && !projectName.isEmpty()) { + message = "[" + projectName + "] " + commit.getHash() + ": " + commit.getMessage(); + } else { + message = commit.getHash() + ": " + commit.getMessage(); + } + return new ActivityTreeNode(NodeType.COMMIT, null, null, null, 0, null, null, null, message); + } + + private ActivityTreeNode( + NodeType nodeType, + String dateDisplay, + String hourDisplay, + String branchName, + long seconds, + String timeDisplay, + List commits, + String commitsDisplay, + String commitMessage) { + this.nodeType = nodeType; + this.dateDisplay = dateDisplay; + this.hourDisplay = hourDisplay; + this.branchName = branchName; + this.seconds = seconds; + this.timeDisplay = timeDisplay; + this.commits = commits != null ? commits : new ArrayList<>(); + this.commitsDisplay = commitsDisplay; + this.commitMessage = commitMessage; + } + + public boolean isDailyNode() { + return nodeType == NodeType.DAILY; + } + + public boolean isCommitNode() { + return nodeType == NodeType.COMMIT; + } + + public NodeType getNodeType() { + return nodeType; + } + + public String getDateOrHourDisplay() { + return switch (nodeType) { + case DAILY -> dateDisplay; + case HOURLY -> hourDisplay; + case COMMIT -> commitMessage; + }; + } + + public String getBranchName() { + return branchName; + } + + public long getSeconds() { + return seconds; + } + + public String getTimeDisplay() { + return nodeType == NodeType.COMMIT ? "" : timeDisplay; + } + + public List getCommits() { + return commits; + } + + public String getCommitsDisplay() { + return nodeType == NodeType.COMMIT ? "" : commitsDisplay; + } + + private static String formatBranches(List branches) { + if (branches == null || branches.isEmpty()) { + return "-"; + } + List uniqueBranches = + branches.stream().distinct().filter(b -> !"-".equals(b)).collect(Collectors.toList()); + if (uniqueBranches.isEmpty()) { + return "-"; + } + if (uniqueBranches.size() <= 3) { + return String.join(", ", uniqueBranches); + } + return uniqueBranches.stream().limit(3).collect(Collectors.joining(", ")) + + " (+" + + (uniqueBranches.size() - 3) + + " more)"; + } + + private static String formatTime(long seconds) { + if (seconds <= 0) { + return "0m"; + } + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } + + private static String formatCommitsCount(List commits) { + if (commits == null || commits.isEmpty()) { + return "-"; + } + int count = commits.size(); + return count == 1 ? "1 commit" : count + " commits"; + } + + @Override + public String toString() { + return getDateOrHourDisplay(); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeTableModel.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeTableModel.java new file mode 100644 index 0000000..5f236ca --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/ActivityTreeTableModel.java @@ -0,0 +1,124 @@ +package com.codeclocker.plugin.intellij.toolwindow; + +import com.intellij.ui.treeStructure.treetable.TreeTableModel; +import javax.swing.JTree; +import javax.swing.event.TreeModelListener; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreePath; + +/** Tree table model for displaying collapsible activity data. */ +public class ActivityTreeTableModel implements TreeTableModel { + + private static final String[] COLUMNS = {"Date", "Time", "Commits"}; + private static final Class[] COLUMN_CLASSES = { + TreeTableModel.class, String.class, String.class + }; + + private DefaultMutableTreeNode root; + + public ActivityTreeTableModel() { + this.root = new DefaultMutableTreeNode("Root"); + } + + public void setRoot(DefaultMutableTreeNode root) { + this.root = root; + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Class getColumnClass(int column) { + return COLUMN_CLASSES[column]; + } + + @Override + public Object getValueAt(Object node, int column) { + if (node instanceof ActivityTreeNode activityNode) { + return switch (column) { + case 0 -> activityNode.getDateOrHourDisplay(); + case 1 -> activityNode.getTimeDisplay(); + case 2 -> activityNode.getCommitsDisplay(); + default -> ""; + }; + } + return ""; + } + + @Override + public boolean isCellEditable(Object node, int column) { + return false; + } + + @Override + public void setValueAt(Object value, Object node, int column) { + // Not editable + } + + @Override + public void setTree(JTree tree) { + // Not needed for our implementation + } + + // TreeModel methods + + @Override + public Object getRoot() { + return root; + } + + @Override + public Object getChild(Object parent, int index) { + if (parent instanceof DefaultMutableTreeNode node) { + return node.getChildAt(index); + } + return null; + } + + @Override + public int getChildCount(Object parent) { + if (parent instanceof DefaultMutableTreeNode node) { + return node.getChildCount(); + } + return 0; + } + + @Override + public boolean isLeaf(Object node) { + if (node instanceof DefaultMutableTreeNode mutableNode) { + return mutableNode.isLeaf(); + } + return true; + } + + @Override + public void valueForPathChanged(TreePath path, Object newValue) { + // Not editable + } + + @Override + public int getIndexOfChild(Object parent, Object child) { + if (parent instanceof DefaultMutableTreeNode parentNode + && child instanceof DefaultMutableTreeNode childNode) { + return parentNode.getIndex(childNode); + } + return -1; + } + + @Override + public void addTreeModelListener(TreeModelListener l) { + // Simple implementation - could add listener support if needed + } + + @Override + public void removeTreeModelListener(TreeModelListener l) { + // Simple implementation + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java new file mode 100644 index 0000000..7540956 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java @@ -0,0 +1,629 @@ +package com.codeclocker.plugin.intellij.toolwindow; + +import com.codeclocker.plugin.intellij.local.CommitRecord; +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; +import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; +import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger; +import com.codeclocker.plugin.intellij.toolwindow.export.ActivityCsvExporter; +import com.codeclocker.plugin.intellij.toolwindow.export.ExportDialog; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.actionSystem.ActionToolbar; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileChooser.FileChooserFactory; +import com.intellij.openapi.fileChooser.FileSaverDescriptor; +import com.intellij.openapi.fileChooser.FileSaverDialog; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.Messages; +import com.intellij.openapi.vfs.VirtualFileWrapper; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.ui.treeStructure.treetable.TreeTable; +import java.awt.BorderLayout; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import javax.swing.JPanel; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; +import javax.swing.tree.DefaultMutableTreeNode; +import org.jetbrains.annotations.NotNull; + +/** Panel displaying branch activity and commits in a collapsible tree table. */ +public class BranchActivityPanel extends JPanel implements Disposable { + + private static final Logger LOG = Logger.getInstance(BranchActivityPanel.class); + + private static final DateTimeFormatter DATE_DISPLAY_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd (EEE)"); + + private static final String ALL_PROJECTS = "All Projects"; + + private static final int AUTO_REFRESH_INTERVAL_MS = 10_000; // 10 seconds + + private final Project project; + private final TreeTable treeTable; + private final ActivityTreeTableModel treeTableModel; + private final ComboBox projectComboBox; + private final javax.swing.Timer autoRefreshTimer; + + private String selectedProject; + + public BranchActivityPanel(Project project) { + this.project = project; + this.selectedProject = ALL_PROJECTS; + this.treeTableModel = new ActivityTreeTableModel(); + this.treeTable = new TreeTable(treeTableModel); + this.projectComboBox = new ComboBox<>(); + + setLayout(new BorderLayout()); + + // Create toolbar + JPanel toolbarPanel = createToolbarPanel(); + add(toolbarPanel, BorderLayout.NORTH); + + // Configure tree table + configureTreeTable(); + + // Add tree table in scroll pane + JBScrollPane scrollPane = new JBScrollPane(treeTable); + add(scrollPane, BorderLayout.CENTER); + + // Load initial data + refreshData(); + + // Refresh data when panel becomes visible (tool window opened) + addAncestorListener( + new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + refreshData(); + } + + @Override + public void ancestorRemoved(AncestorEvent event) { + // Not needed + } + + @Override + public void ancestorMoved(AncestorEvent event) { + // Not needed + } + }); + + // Setup auto-refresh timer + autoRefreshTimer = new javax.swing.Timer(AUTO_REFRESH_INTERVAL_MS, e -> refreshData()); + autoRefreshTimer.start(); + } + + private JPanel createToolbarPanel() { + DefaultActionGroup actionGroup = new DefaultActionGroup(); + + // Refresh action + AnAction refreshAction = + new AnAction("Refresh", "Refresh branch activity data", AllIcons.Actions.Refresh) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + refreshData(); + } + }; + actionGroup.add(refreshAction); + + // Expand all action + AnAction expandAllAction = + new AnAction("Expand All", "Expand all daily nodes", AllIcons.Actions.Expandall) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + expandAll(); + } + }; + actionGroup.add(expandAllAction); + + // Collapse all action + AnAction collapseAllAction = + new AnAction("Collapse All", "Collapse all daily nodes", AllIcons.Actions.Collapseall) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + collapseAll(); + } + }; + actionGroup.add(collapseAllAction); + + // Export action + AnAction exportAction = + new AnAction("Export", "Export activity data to CSV", AllIcons.ToolbarDecorator.Export) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + exportActivityData(); + } + }; + actionGroup.add(exportAction); + + ActionToolbar toolbar = + ActionManager.getInstance().createActionToolbar("BranchActivityToolbar", actionGroup, true); + toolbar.setTargetComponent(this); + + JPanel toolbarPanel = new JPanel(new BorderLayout()); + toolbarPanel.add(toolbar.getComponent(), BorderLayout.WEST); + + // Add project selector combo box + projectComboBox.addActionListener( + e -> { + String selected = (String) projectComboBox.getSelectedItem(); + if (selected != null && !selected.equals(selectedProject)) { + selectedProject = selected; + refreshData(); + } + }); + + JPanel filterPanel = new JPanel(); + filterPanel.add(projectComboBox); + toolbarPanel.add(filterPanel, BorderLayout.EAST); + + return toolbarPanel; + } + + private void configureTreeTable() { + treeTable.setRootVisible(false); + treeTable.setRowHeight(25); + treeTable.getColumnModel().getColumn(0).setPreferredWidth(200); // Date/Hour + treeTable.getColumnModel().getColumn(1).setPreferredWidth(100); // Time + treeTable.getColumnModel().getColumn(2).setPreferredWidth(400); // Commits + } + + private void expandAll() { + for (int i = 0; i < treeTable.getTree().getRowCount(); i++) { + treeTable.getTree().expandRow(i); + } + } + + private void collapseAll() { + for (int i = treeTable.getTree().getRowCount() - 1; i >= 0; i--) { + treeTable.getTree().collapseRow(i); + } + } + + public void refreshData() { + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + if (dataProvider == null) { + return; + } + + // Save expanded state before refresh + Set expandedNodes = saveExpandedState(); + + // Data is returned with hourKeys in local timezone + Map> allData = + dataProvider.getAllDataInLocalTimezone(); + + // Add unsaved deltas from accumulators to match status bar widget totals + mergeUnsavedDeltas(allData); + + // Update project dropdown with available projects + updateProjectDropdown(allData); + + DefaultMutableTreeNode root = buildTreeStructure(allData); + treeTableModel.setRoot(root); + + // Refresh the tree table + treeTable.setModel(treeTableModel); + configureTreeTable(); + + // Restore expanded state after refresh + restoreExpandedState(expandedNodes); + } + + private Set saveExpandedState() { + Set expandedNodes = new HashSet<>(); + javax.swing.JTree tree = treeTable.getTree(); + + for (int i = 0; i < tree.getRowCount(); i++) { + if (tree.isExpanded(i)) { + javax.swing.tree.TreePath path = tree.getPathForRow(i); + Object node = path.getLastPathComponent(); + if (node instanceof ActivityTreeNode activityNode) { + expandedNodes.add(activityNode.getDateOrHourDisplay()); + } + } + } + return expandedNodes; + } + + private void restoreExpandedState(Set expandedNodes) { + if (expandedNodes.isEmpty()) { + return; + } + + javax.swing.JTree tree = treeTable.getTree(); + + // Iterate through rows and expand matching nodes + for (int i = 0; i < tree.getRowCount(); i++) { + javax.swing.tree.TreePath path = tree.getPathForRow(i); + Object node = path.getLastPathComponent(); + if (node instanceof ActivityTreeNode activityNode) { + if (expandedNodes.contains(activityNode.getDateOrHourDisplay())) { + tree.expandPath(path); + } + } + } + } + + /** + * Merge unsaved deltas from accumulators into the data map. This ensures the Activity Report + * shows the same totals as the status bar widget. + */ + private void mergeUnsavedDeltas(Map> allData) { + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + if (logger == null) { + return; + } + + String currentHourKey = + java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd-HH")); + + // Get unsaved deltas for all projects and merge into current hour + Map hourData = + allData.computeIfAbsent(currentHourKey, k -> new LinkedHashMap<>()); + + // Check each project that might have unsaved data + for (String projectName : getAllProjectNames(allData)) { + long unsavedDelta = logger.getProjectUnsavedDelta(projectName); + if (unsavedDelta > 0) { + ProjectActivitySnapshot existing = hourData.get(projectName); + if (existing != null) { + // Add unsaved delta to existing snapshot + ProjectActivitySnapshot updated = + new ProjectActivitySnapshot( + existing.getCodedTimeSeconds() + unsavedDelta, + existing.getAdditions(), + existing.getRemovals(), + existing.isReported()); + updated.setBranchActivity(existing.getBranchActivity()); + updated.setCommits(existing.getCommits()); + hourData.put(projectName, updated); + } else { + // Create new snapshot with just the unsaved delta + hourData.put(projectName, new ProjectActivitySnapshot(unsavedDelta, 0, 0, false)); + } + } + } + } + + private Set getAllProjectNames( + Map> allData) { + Set projectNames = new HashSet<>(); + for (Map hourData : allData.values()) { + projectNames.addAll(hourData.keySet()); + } + // Also check accumulator for projects that might only have unsaved data + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + if (logger != null) { + // The logger doesn't expose project names directly, so we rely on local state + // Projects with only unsaved data will be picked up on next flush + } + return projectNames; + } + + private void updateProjectDropdown(Map> data) { + // Collect all unique project names from data + List projects = + data.values().stream() + .flatMap(hourData -> hourData.keySet().stream()) + .distinct() + .sorted() + .collect(Collectors.toList()); + + // Remember current selection + String currentSelection = selectedProject; + + // Update dropdown items + projectComboBox.removeAllItems(); + projectComboBox.addItem(ALL_PROJECTS); + for (String proj : projects) { + projectComboBox.addItem(proj); + } + + // Restore selection if it still exists, otherwise default to All Projects + if (currentSelection != null + && (ALL_PROJECTS.equals(currentSelection) || projects.contains(currentSelection))) { + projectComboBox.setSelectedItem(currentSelection); + } else { + projectComboBox.setSelectedItem(ALL_PROJECTS); + selectedProject = ALL_PROJECTS; + } + } + + private DefaultMutableTreeNode buildTreeStructure( + Map> data) { + DefaultMutableTreeNode root = new DefaultMutableTreeNode("Root"); + + if (ALL_PROJECTS.equals(selectedProject)) { + // All Projects mode: Day → Project → Commits + buildTreeForAllProjects(root, data); + } else { + // Single project mode: Day → Hour → Commits + buildTreeForSingleProject(root, data); + } + + return root; + } + + private void buildTreeForAllProjects( + DefaultMutableTreeNode root, Map> data) { + // Group data by date, then by project + Map> dateProjectMap = new LinkedHashMap<>(); + + for (Map.Entry> hourEntry : data.entrySet()) { + String hourKey = hourEntry.getKey(); + String date = extractDateFromHourKey(hourKey); + + for (Map.Entry projEntry : hourEntry.getValue().entrySet()) { + String projectName = projEntry.getKey(); + ProjectActivitySnapshot snapshot = projEntry.getValue(); + + dateProjectMap + .computeIfAbsent(date, k -> new LinkedHashMap<>()) + .computeIfAbsent(projectName, k -> new ProjectDailyAggregate()) + .add(snapshot); + } + } + + // Sort dates descending (newest first) + List sortedDates = + dateProjectMap.keySet().stream() + .sorted(Comparator.reverseOrder()) + .collect(Collectors.toList()); + + for (String date : sortedDates) { + Map projectsForDate = dateProjectMap.get(date); + + // Calculate daily totals + long totalSeconds = + projectsForDate.values().stream().mapToLong(ProjectDailyAggregate::getTotalSeconds).sum(); + List allCommits = + projectsForDate.values().stream() + .flatMap(agg -> agg.getCommits().stream()) + .distinct() + .collect(Collectors.toList()); + + // Create daily parent node + String dateDisplay = formatDateDisplay(date); + ActivityTreeNode dailyNode = + ActivityTreeNode.createDailyNode( + dateDisplay, totalSeconds, new ArrayList<>(), allCommits); + + // Sort projects by coding time descending (biggest to smallest) + projectsForDate.entrySet().stream() + .sorted( + (e1, e2) -> + Long.compare(e2.getValue().getTotalSeconds(), e1.getValue().getTotalSeconds())) + .forEach( + entry -> { + String projectName = entry.getKey(); + ProjectDailyAggregate aggregate = entry.getValue(); + + // Create project row using BranchActivityRow + BranchActivityRow projectRow = + new BranchActivityRow( + date, + projectName, + "-", + aggregate.getTotalSeconds(), + formatTime(aggregate.getTotalSeconds()), + aggregate.getCommits(), + formatCommitsDisplay(aggregate.getCommits())); + + ActivityTreeNode projectNode = ActivityTreeNode.createHourlyNode(projectRow); + + // Add commits as children (no project prefix since parent is the project) + for (CommitRecord commit : aggregate.getCommits()) { + ActivityTreeNode commitNode = ActivityTreeNode.createCommitNode(commit, null); + projectNode.add(commitNode); + } + + dailyNode.add(projectNode); + }); + + root.add(dailyNode); + } + } + + private void buildTreeForSingleProject( + DefaultMutableTreeNode root, Map> data) { + // Group data by date + Map dateMap = new LinkedHashMap<>(); + + for (Map.Entry> hourEntry : data.entrySet()) { + String hourKey = hourEntry.getKey(); + String date = extractDateFromHourKey(hourKey); + + ProjectActivitySnapshot snapshot = hourEntry.getValue().get(selectedProject); + if (snapshot != null) { + dateMap.computeIfAbsent(date, k -> new ProjectDailyAggregate()).add(snapshot); + } + } + + // Sort dates descending (newest first) + List sortedDates = + dateMap.keySet().stream().sorted(Comparator.reverseOrder()).collect(Collectors.toList()); + + for (String date : sortedDates) { + ProjectDailyAggregate aggregate = dateMap.get(date); + + // Create daily parent node + String dateDisplay = formatDateDisplay(date); + ActivityTreeNode dailyNode = + ActivityTreeNode.createDailyNode( + dateDisplay, aggregate.getTotalSeconds(), new ArrayList<>(), aggregate.getCommits()); + + // Add commits directly as children of daily node + for (CommitRecord commit : aggregate.getCommits()) { + ActivityTreeNode commitNode = ActivityTreeNode.createCommitNode(commit, null); + dailyNode.add(commitNode); + } + + root.add(dailyNode); + } + } + + /** Helper class to aggregate project data for a day. */ + private static class ProjectDailyAggregate { + private long totalSeconds = 0; + private final List commits = new ArrayList<>(); + + void add(ProjectActivitySnapshot snapshot) { + totalSeconds += snapshot.getCodedTimeSeconds(); + for (CommitRecord commit : snapshot.getCommits()) { + if (commits.stream().noneMatch(c -> c.getHash().equals(commit.getHash()))) { + commits.add(commit); + } + } + } + + long getTotalSeconds() { + return totalSeconds; + } + + List getCommits() { + return commits; + } + } + + private String extractDateFromHourKey(String hourKey) { + // hourKey format: yyyy-MM-dd-HH + if (hourKey != null && hourKey.length() >= 10) { + return hourKey.substring(0, 10); + } + return hourKey; + } + + private String formatDateDisplay(String date) { + try { + LocalDate localDate = LocalDate.parse(date); + String formatted = localDate.format(DATE_DISPLAY_FORMATTER); + if (localDate.equals(LocalDate.now())) { + return formatted + " - Today"; + } + return formatted; + } catch (Exception e) { + return date; + } + } + + private String formatTime(long seconds) { + if (seconds <= 0) { + return "0m"; + } + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } + + private String formatCommitsDisplay(List commits) { + if (commits.isEmpty()) { + return "-"; + } + if (commits.size() == 1) { + CommitRecord c = commits.get(0); + String msg = c.getMessage(); + if (msg.length() > 40) { + msg = msg.substring(0, 37) + "..."; + } + return c.getHash() + ": " + msg; + } + // Multiple commits - show count and hashes + String hashes = + commits.stream().map(CommitRecord::getHash).limit(3).collect(Collectors.joining(", ")); + if (commits.size() > 3) { + hashes += "..."; + } + return commits.size() + " commits: " + hashes; + } + + private void exportActivityData() { + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + if (dataProvider == null) { + Messages.showErrorDialog(project, "Failed to load activity data", "Export Error"); + return; + } + + Map> data = + dataProvider.getAllDataInLocalTimezone(); + if (data.isEmpty()) { + Messages.showInfoMessage(project, "No activity data to export", "Export"); + return; + } + + // Get date range from data + ActivityCsvExporter exporter = new ActivityCsvExporter(); + LocalDate[] dateRange = exporter.getDateRange(data); + LocalDate defaultFrom = dateRange != null ? dateRange[0] : LocalDate.now().minusDays(7); + LocalDate defaultTo = dateRange != null ? dateRange[1] : LocalDate.now(); + + // Show export dialog + ExportDialog dialog = new ExportDialog(defaultFrom, defaultTo); + if (!dialog.showAndGet()) { + return; + } + + LocalDate fromDate = dialog.getFromDate(); + LocalDate toDate = dialog.getToDate(); + + // Generate CSV content + String csvContent = exporter.exportToCsv(data, fromDate, toDate); + + // Show file save dialog + String defaultFileName = "activity-report-" + fromDate + "-to-" + toDate + ".csv"; + FileSaverDescriptor descriptor = + new FileSaverDescriptor("Export Activity Report", "Save activity report as CSV", "csv"); + FileSaverDialog saveDialog = + FileChooserFactory.getInstance().createSaveFileDialog(descriptor, project); + VirtualFileWrapper fileWrapper = saveDialog.save(defaultFileName); + + if (fileWrapper == null) { + return; + } + + // Write CSV to file + try { + Files.writeString(fileWrapper.getFile().toPath(), csvContent, StandardCharsets.UTF_8); + Messages.showInfoMessage( + project, + "Activity report exported to:\n" + fileWrapper.getFile().getAbsolutePath(), + "Export Successful"); + } catch (IOException e) { + LOG.error("Failed to export activity report", e); + Messages.showErrorDialog(project, "Failed to write file: " + e.getMessage(), "Export Error"); + } + } + + @Override + public void dispose() { + if (autoRefreshTimer != null) { + autoRefreshTimer.stop(); + } + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityRow.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityRow.java new file mode 100644 index 0000000..d53c39d --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityRow.java @@ -0,0 +1,14 @@ +package com.codeclocker.plugin.intellij.toolwindow; + +import com.codeclocker.plugin.intellij.local.CommitRecord; +import java.util.List; + +/** Data row for branch activity table display. */ +public record BranchActivityRow( + String hourKey, + String hourDisplay, + String branchName, + long seconds, + String timeDisplay, + List commits, + String commitsDisplay) {} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java new file mode 100644 index 0000000..3c15a5f --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java @@ -0,0 +1,25 @@ +package com.codeclocker.plugin.intellij.toolwindow; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentFactory; +import org.jetbrains.annotations.NotNull; + +/** Factory for creating the Branch Activity tool window. */ +public class BranchActivityToolWindowFactory implements ToolWindowFactory { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + BranchActivityPanel panel = new BranchActivityPanel(project); + ContentFactory contentFactory = ContentFactory.getInstance(); + Content content = contentFactory.createContent(panel, "Activity", false); + toolWindow.getContentManager().addContent(content); + } + + @Override + public boolean shouldBeAvailable(@NotNull Project project) { + return true; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ActivityCsvExporter.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ActivityCsvExporter.java new file mode 100644 index 0000000..de4f03b --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ActivityCsvExporter.java @@ -0,0 +1,220 @@ +package com.codeclocker.plugin.intellij.toolwindow.export; + +import com.codeclocker.plugin.intellij.local.CommitRecord; +import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** Exports activity data to CSV format for invoicing purposes. */ +public class ActivityCsvExporter { + + private static final String CSV_HEADER = "Date,Project,Hours,Description"; + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * Generates CSV content from activity data. + * + * @param data hourKey -> (projectName -> snapshot) map with hourKeys in local timezone + * @param fromDate start date (inclusive) + * @param toDate end date (inclusive) + * @return CSV content as string + */ + public String exportToCsv( + Map> data, + LocalDate fromDate, + LocalDate toDate) { + + // Aggregate data by date and project + Map> dailyData = aggregateByDateAndProject(data); + + // Filter by date range and sort + List rows = + dailyData.entrySet().stream() + .filter(entry -> isDateInRange(entry.getKey(), fromDate, toDate)) + .sorted(Map.Entry.>comparingByKey().reversed()) + .flatMap( + dateEntry -> + dateEntry.getValue().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map( + projectEntry -> + new CsvRow( + dateEntry.getKey(), + projectEntry.getKey(), + projectEntry.getValue().totalSeconds, + projectEntry.getValue().commits))) + .toList(); + + return generateCsv(rows); + } + + /** + * Gets the date range from the data. + * + * @param data hourKey -> (projectName -> snapshot) map + * @return array with [minDate, maxDate], or null if no data + */ + public LocalDate[] getDateRange(Map> data) { + if (data == null || data.isEmpty()) { + return null; + } + + LocalDate minDate = null; + LocalDate maxDate = null; + + for (String hourKey : data.keySet()) { + LocalDate date = extractDate(hourKey); + if (date != null) { + if (minDate == null || date.isBefore(minDate)) { + minDate = date; + } + if (maxDate == null || date.isAfter(maxDate)) { + maxDate = date; + } + } + } + + if (minDate == null || maxDate == null) { + return null; + } + + return new LocalDate[] {minDate, maxDate}; + } + + private Map> aggregateByDateAndProject( + Map> data) { + + Map> result = new LinkedHashMap<>(); + + for (Map.Entry> hourEntry : data.entrySet()) { + String hourKey = hourEntry.getKey(); + String date = extractDateString(hourKey); + if (date == null) { + continue; + } + + for (Map.Entry projectEntry : + hourEntry.getValue().entrySet()) { + String projectName = projectEntry.getKey(); + ProjectActivitySnapshot snapshot = projectEntry.getValue(); + + result + .computeIfAbsent(date, k -> new LinkedHashMap<>()) + .computeIfAbsent(projectName, k -> new DailyProjectData()) + .add(snapshot); + } + } + + return result; + } + + private boolean isDateInRange(String dateStr, LocalDate fromDate, LocalDate toDate) { + try { + LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER); + return !date.isBefore(fromDate) && !date.isAfter(toDate); + } catch (Exception e) { + return false; + } + } + + private String extractDateString(String hourKey) { + if (hourKey != null && hourKey.length() >= 10) { + return hourKey.substring(0, 10); + } + return null; + } + + private LocalDate extractDate(String hourKey) { + String dateStr = extractDateString(hourKey); + if (dateStr != null) { + try { + return LocalDate.parse(dateStr, DATE_FORMATTER); + } catch (Exception e) { + return null; + } + } + return null; + } + + private String generateCsv(List rows) { + StringBuilder sb = new StringBuilder(); + sb.append(CSV_HEADER).append("\n"); + + for (CsvRow row : rows) { + sb.append(row.date).append(","); + sb.append(escapeCsv(row.project)).append(","); + sb.append(formatHours(row.totalSeconds)).append(","); + sb.append(escapeCsv(formatCommits(row.commits))).append("\n"); + } + + return sb.toString(); + } + + private String formatHours(long seconds) { + double hours = seconds / 3600.0; + return String.format("%.2f", hours); + } + + private String formatCommits(List commits) { + if (commits == null || commits.isEmpty()) { + return ""; + } + + return commits.stream() + .sorted(Comparator.comparingLong(CommitRecord::getTimestamp)) + .map(c -> c.getHash() + ": " + truncateMessage(c.getMessage())) + .collect(Collectors.joining("; ")); + } + + private String truncateMessage(String message) { + if (message == null) { + return ""; + } + // Take first line only + int newlineIdx = message.indexOf('\n'); + if (newlineIdx > 0) { + message = message.substring(0, newlineIdx); + } + // Truncate if too long + if (message.length() > 80) { + message = message.substring(0, 77) + "..."; + } + return message; + } + + private String escapeCsv(String value) { + if (value == null) { + return ""; + } + // If contains comma, quote, or newline, wrap in quotes and escape quotes + if (value.contains(",") || value.contains("\"") || value.contains("\n")) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + return value; + } + + /** Helper class to aggregate daily data per project. */ + private static class DailyProjectData { + long totalSeconds = 0; + List commits = new ArrayList<>(); + + void add(ProjectActivitySnapshot snapshot) { + totalSeconds += snapshot.getCodedTimeSeconds(); + for (CommitRecord commit : snapshot.getCommits()) { + if (commits.stream().noneMatch(c -> c.getHash().equals(commit.getHash()))) { + commits.add(commit); + } + } + } + } + + /** Helper record for CSV row data. */ + private record CsvRow( + String date, String project, long totalSeconds, List commits) {} +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ExportDialog.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ExportDialog.java new file mode 100644 index 0000000..72c75ac --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ExportDialog.java @@ -0,0 +1,78 @@ +package com.codeclocker.plugin.intellij.toolwindow.export; + +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.FormBuilder; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Calendar; +import java.util.Date; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerDateModel; +import org.jetbrains.annotations.Nullable; + +/** Dialog for selecting date range for activity export. */ +public class ExportDialog extends DialogWrapper { + + private JSpinner fromDateSpinner; + private JSpinner toDateSpinner; + + private final LocalDate defaultFromDate; + private final LocalDate defaultToDate; + + public ExportDialog(LocalDate defaultFromDate, LocalDate defaultToDate) { + super(true); + this.defaultFromDate = defaultFromDate != null ? defaultFromDate : LocalDate.now().minusDays(7); + this.defaultToDate = defaultToDate != null ? defaultToDate : LocalDate.now(); + setTitle("Export Activity Report"); + init(); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + // From date spinner + fromDateSpinner = createDateSpinner(defaultFromDate); + + // To date spinner + toDateSpinner = createDateSpinner(defaultToDate); + + return FormBuilder.createFormBuilder() + .addLabeledComponent(new JBLabel("From:"), fromDateSpinner) + .addVerticalGap(10) + .addLabeledComponent(new JBLabel("To:"), toDateSpinner) + .addVerticalGap(10) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } + + private JSpinner createDateSpinner(LocalDate initialDate) { + Date date = Date.from(initialDate.atStartOfDay(ZoneId.systemDefault()).toInstant()); + SpinnerDateModel model = new SpinnerDateModel(date, null, null, Calendar.DAY_OF_MONTH); + JSpinner spinner = new JSpinner(model); + JSpinner.DateEditor editor = new JSpinner.DateEditor(spinner, "yyyy-MM-dd"); + spinner.setEditor(editor); + return spinner; + } + + public LocalDate getFromDate() { + Date date = (Date) fromDateSpinner.getValue(); + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + + public LocalDate getToDate() { + Date date = (Date) toDateSpinner.getValue(); + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + + @Override + protected void doOKAction() { + // Validate that from <= to + if (getFromDate().isAfter(getToDate())) { + setErrorText("'From' date must be before or equal to 'To' date"); + return; + } + super.doOKAction(); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingPersistence.java b/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingPersistence.java new file mode 100644 index 0000000..495fc17 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingPersistence.java @@ -0,0 +1,54 @@ +package com.codeclocker.plugin.intellij.tracking; + +import com.intellij.ide.util.PropertiesComponent; + +/** Handles persistent storage of tracking behavior settings. */ +public class TrackingPersistence { + + private static final String PAUSE_ON_FOCUS_LOST = "com.codeclocker.tracking.pause-on-focus-lost"; + private static final String INACTIVITY_TIMEOUT_SECONDS = + "com.codeclocker.tracking.inactivity-timeout-seconds"; + + private static final boolean DEFAULT_PAUSE_ON_FOCUS_LOST = true; + private static final int DEFAULT_INACTIVITY_TIMEOUT_SECONDS = 120; // 2 minutes + + /** + * Check if tracking should pause when IDE loses focus. + * + * @return true if tracking should pause on focus lost (default: true) + */ + public static boolean isPauseOnFocusLostEnabled() { + return PropertiesComponent.getInstance() + .getBoolean(PAUSE_ON_FOCUS_LOST, DEFAULT_PAUSE_ON_FOCUS_LOST); + } + + /** + * Enable or disable pause on focus lost. + * + * @param enabled true to pause tracking when IDE loses focus + */ + public static void setPauseOnFocusLostEnabled(boolean enabled) { + PropertiesComponent.getInstance() + .setValue(PAUSE_ON_FOCUS_LOST, enabled, DEFAULT_PAUSE_ON_FOCUS_LOST); + } + + /** + * Get the inactivity timeout in seconds. + * + * @return timeout in seconds (default: 120) + */ + public static int getInactivityTimeoutSeconds() { + return PropertiesComponent.getInstance() + .getInt(INACTIVITY_TIMEOUT_SECONDS, DEFAULT_INACTIVITY_TIMEOUT_SECONDS); + } + + /** + * Set the inactivity timeout. + * + * @param seconds timeout in seconds + */ + public static void setInactivityTimeoutSeconds(int seconds) { + PropertiesComponent.getInstance() + .setValue(INACTIVITY_TIMEOUT_SECONDS, seconds, DEFAULT_INACTIVITY_TIMEOUT_SECONDS); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingSettingsDialog.java b/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingSettingsDialog.java new file mode 100644 index 0000000..9657139 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/tracking/TrackingSettingsDialog.java @@ -0,0 +1,84 @@ +package com.codeclocker.plugin.intellij.tracking; + +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.openapi.ui.ValidationInfo; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.util.ui.FormBuilder; +import java.awt.FlowLayout; +import javax.swing.JComponent; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import org.jetbrains.annotations.Nullable; + +/** Dialog for configuring tracking behavior settings. */ +public class TrackingSettingsDialog extends DialogWrapper { + + private JBCheckBox pauseOnFocusLostCheckbox; + private JSpinner minutesSpinner; + private JSpinner secondsSpinner; + + public TrackingSettingsDialog() { + super(true); + setTitle("Tracking Settings"); + init(); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + boolean pauseOnFocusLost = TrackingPersistence.isPauseOnFocusLostEnabled(); + int totalSeconds = TrackingPersistence.getInactivityTimeoutSeconds(); + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + + pauseOnFocusLostCheckbox = new JBCheckBox("Pause when IDE loses focus", pauseOnFocusLost); + + minutesSpinner = new JSpinner(new SpinnerNumberModel(minutes, 0, 60, 1)); + secondsSpinner = new JSpinner(new SpinnerNumberModel(seconds, 0, 59, 5)); + + JPanel timePanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0)); + timePanel.add(minutesSpinner); + timePanel.add(new JBLabel("min")); + timePanel.add(secondsSpinner); + timePanel.add(new JBLabel("sec")); + + return FormBuilder.createFormBuilder() + .addComponent(pauseOnFocusLostCheckbox) + .addVerticalGap(10) + .addLabeledComponent("Pause after inactivity:", timePanel) + .addComponentFillVertically(new JPanel(), 0) + .getPanel(); + } + + @Override + protected @Nullable ValidationInfo doValidate() { + int minutes = (Integer) minutesSpinner.getValue(); + int seconds = (Integer) secondsSpinner.getValue(); + int totalSeconds = minutes * 60 + seconds; + + if (totalSeconds < 10) { + return new ValidationInfo("Inactivity timeout must be at least 10 seconds", secondsSpinner); + } + if (totalSeconds > 3600) { + return new ValidationInfo("Inactivity timeout cannot exceed 60 minutes", minutesSpinner); + } + return null; + } + + @Override + protected void doOKAction() { + int minutes = (Integer) minutesSpinner.getValue(); + int seconds = (Integer) secondsSpinner.getValue(); + int totalSeconds = minutes * 60 + seconds; + + TrackingPersistence.setPauseOnFocusLostEnabled(pauseOnFocusLostCheckbox.isSelected()); + TrackingPersistence.setInactivityTimeoutSeconds(totalSeconds); + super.doOKAction(); + } + + /** Show the tracking settings dialog. */ + public static void showDialog() { + new TrackingSettingsDialog().showAndGet(); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java index 5cbdf8f..9019ef1 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java @@ -1,7 +1,5 @@ package com.codeclocker.plugin.intellij.widget; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_INIT_SECONDS; -import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_STOP_WATCH; import static com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker.GLOBAL_ADDITIONS; import static com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker.GLOBAL_REMOVALS; import static org.apache.commons.collections.MapUtils.isEmpty; @@ -9,17 +7,14 @@ import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle; import com.codeclocker.plugin.intellij.config.Config; -import com.codeclocker.plugin.intellij.local.LocalStateRepository; +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient; import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient.DailyTimeResponse; import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient.ProjectStats; -import com.codeclocker.plugin.intellij.services.TimeTrackerWidgetService; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectManager; import com.intellij.util.concurrency.AppExecutorUtil; import java.time.LocalDate; import java.time.ZoneId; @@ -30,6 +25,10 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +/** + * Initializes VCS counters (additions/removals) from local state or hub on startup. Time tracking + * data is read directly from LocalStateRepository by the widget, so no initialization is needed. + */ public class TimeTrackerInitializer { private static final Logger LOG = Logger.getInstance(TimeTrackerInitializer.class); @@ -73,26 +72,27 @@ private static void fetchTimeAndInitialize() { ApplicationManager.getApplication().getService(DailyTimeHttpClient.class); DailyTimeResponse response = getDailyTimeFromHub(httpClient, apiKey); if (response.isError()) { - LOG.warn("Failed to fetch daily time, initializing widgets with 0 and starting retry task"); - initializeAllProjectWidgets(Map.of(), false); + LOG.warn( + "Failed to fetch daily time, initializing from local state and starting retry task"); + initializeFromLocalState(); startRetryTask(); return; } if (response.isSubscriptionExpired()) { - LOG.info("Subscription expired, showing exclamation mark in widgets"); + LOG.info("Subscription expired, initializing from local state"); cancelRetryTask(); - initializeAllProjectWidgets(Map.of(), true); + initializeFromLocalState(); return; } Map projectStats = response.getProjects(); LOG.info("Fetched daily time for project count: " + projectStats.size()); cancelRetryTask(); - initializeAllProjectWidgets(projectStats, false); + initializeFromHubData(projectStats); } catch (Exception e) { LOG.error("Error initializing timer widgets", e); - initializeAllProjectWidgets(Map.of(), false); + initializeFromLocalState(); startRetryTask(); } } @@ -110,66 +110,44 @@ private static DailyTimeHttpClient.DailyTimeResponse getDailyTimeFromHub( return response; } - private static void initializeAllProjectWidgets( - Map projectStats, boolean subscriptionExpired) { - if (isEmpty(projectStats) && initialized) { - LOG.debug( - "Skipping reinitializing timers with empty project stats since they are already initialized"); + private static void initializeFromHubData(Map projectStats) { + if (initialized) { + LOG.debug("Already initialized, skipping re-initialization from hub data"); return; } - long totalGlobalSeconds = - projectStats.values().stream() - .mapToLong(DailyTimeHttpClient.ProjectStats::timeSpentSeconds) - .sum(); - long totalAdditions = - projectStats.values().stream().mapToLong(DailyTimeHttpClient.ProjectStats::additions).sum(); - long totalRemovals = - projectStats.values().stream().mapToLong(DailyTimeHttpClient.ProjectStats::removals).sum(); + if (isEmpty(projectStats)) { + LOG.debug("No project stats from hub, skipping initialization"); + initialized = true; + return; + } + + // Calculate totals for VCS counters + long totalAdditions = 0; + long totalRemovals = 0; + + for (Map.Entry entry : projectStats.entrySet()) { + ProjectStats stats = entry.getValue(); + totalAdditions += stats.additions(); + totalRemovals += stats.removals(); + } LOG.debug( - "Total time across all projects: {}s, additions: {}, removals: {}", - totalGlobalSeconds, + "Initializing VCS counters from hub: additions: {}, removals: {}", totalAdditions, totalRemovals); - // Reset the global stopwatch since the backend total already includes all accumulated time - GLOBAL_STOP_WATCH.reset(); - GLOBAL_INIT_SECONDS.set(totalGlobalSeconds); - // Initialize global VCS counters with data from backend + // Note: Time data is now read directly from LocalStateRepository, no need to load into + // accumulators GLOBAL_ADDITIONS.set(totalAdditions); GLOBAL_REMOVALS.set(totalRemovals); - LOG.debug( - "Initialized GLOBAL_ADDITIONS: {}, GLOBAL_REMOVALS: {}", totalAdditions, totalRemovals); initializeVcsChanges(projectStats); - initializeCodingTime(projectStats); initialized = true; } - private static void initializeCodingTime(Map projectStats) { - Project[] openProjects = ProjectManager.getInstance().getOpenProjects(); - for (Project project : openProjects) { - String projectName = project.getName(); - ProjectStats stats = projectStats.get(projectName); - long initialSeconds = stats != null ? stats.timeSpentSeconds() : 0L; - - initializeTimeTrackerWidget( - project, initialSeconds, "Initialized timer widget for project {} with {}s", projectName); - } - } - - private static void initializeTimeTrackerWidget( - Project project, long initialProjectSeconds, String message, String projectName) { - TimeTrackerWidgetService service = project.getService(TimeTrackerWidgetService.class); - if (service != null) { - service.initialize(initialProjectSeconds); - LOG.debug(message, projectName, initialProjectSeconds); - } - } - private static void initializeVcsChanges(Map projectStats) { ChangesActivityTracker changesTracker = ApplicationManager.getApplication().getService(ChangesActivityTracker.class); @@ -216,48 +194,57 @@ private static synchronized void cancelRetryTask() { } private static void initializeFromLocalState() { - LocalStateRepository localState = - ApplicationManager.getApplication().getService(LocalStateRepository.class); - Map todayStats = aggregateTodayStats(localState); + if (initialized) { + LOG.debug("Already initialized, skipping re-initialization from local state"); + return; + } - if (todayStats.isEmpty() && initialized) { - LOG.debug("No local state data for today and already initialized, skipping"); + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + if (dataProvider == null) { + LOG.warn("LocalActivityDataProvider not available, skipping initialization"); return; } - long totalTime = - todayStats.values().stream().mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds).sum(); - long totalAdditions = - todayStats.values().stream().mapToLong(ProjectActivitySnapshot::getAdditions).sum(); - long totalRemovals = - todayStats.values().stream().mapToLong(ProjectActivitySnapshot::getRemovals).sum(); + Map todayStats = aggregateTodayStats(dataProvider); + + // Calculate VCS totals from local state + long totalAdditions = 0; + long totalRemovals = 0; + for (Map.Entry entry : todayStats.entrySet()) { + ProjectActivitySnapshot stats = entry.getValue(); + totalAdditions += stats.getAdditions(); + totalRemovals += stats.getRemovals(); + } + + long totalTime = dataProvider.getTodayTotalSeconds(); LOG.info( - "Initializing from local state - total time: " + "Initializing VCS counters from local state - total time: " + totalTime + "s, additions: " + totalAdditions + ", removals: " + totalRemovals); - GLOBAL_STOP_WATCH.reset(); - GLOBAL_INIT_SECONDS.set(totalTime); + // Note: Time data is now read directly from LocalActivityDataProvider, no need to load into + // accumulators GLOBAL_ADDITIONS.set(totalAdditions); GLOBAL_REMOVALS.set(totalRemovals); initializeVcsChangesFromLocalState(todayStats); - initializeCodingTimeFromLocalState(todayStats); initialized = true; } private static Map aggregateTodayStats( - LocalStateRepository localState) { + LocalActivityDataProvider dataProvider) { String todayPrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")); Map aggregated = new HashMap<>(); + // Data from LocalActivityDataProvider is already in local timezone for (Map.Entry> hourEntry : - localState.getAllData().entrySet()) { + dataProvider.getAllDataInLocalTimezone().entrySet()) { if (!hourEntry.getKey().startsWith(todayPrefix)) { continue; } @@ -282,22 +269,6 @@ private static Map aggregateTodayStats( return aggregated; } - private static void initializeCodingTimeFromLocalState( - Map projectStats) { - Project[] openProjects = ProjectManager.getInstance().getOpenProjects(); - for (Project project : openProjects) { - String projectName = project.getName(); - ProjectActivitySnapshot stats = projectStats.get(projectName); - long initialSeconds = stats != null ? stats.getCodedTimeSeconds() : 0L; - - initializeTimeTrackerWidget( - project, - initialSeconds, - "Initialized timer widget from local state for project {} with {}s (total: {}s)", - projectName); - } - } - private static void initializeVcsChangesFromLocalState( Map projectStats) { ChangesActivityTracker changesTracker = diff --git a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java index cab6e55..d6b6eb1 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java @@ -17,6 +17,7 @@ import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimePeriodComparisonDto; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; import com.codeclocker.plugin.intellij.services.vcs.ProjectChangesCounters; +import com.codeclocker.plugin.intellij.tracking.TrackingSettingsDialog; import com.intellij.ide.BrowserUtil; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.project.Project; @@ -25,9 +26,10 @@ import com.intellij.openapi.ui.popup.ListSeparator; import com.intellij.openapi.ui.popup.PopupStep; import com.intellij.openapi.ui.popup.util.BaseListPopupStep; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowManager; import java.util.ArrayList; import java.util.List; -import java.util.Map; import org.jetbrains.annotations.Nullable; public class TimeTrackerPopup { @@ -36,6 +38,8 @@ public class TimeTrackerPopup { private static final String SAVE_HISTORY = "Save my history & unlock trends →"; private static final String RENEW_SUBSCRIPTION = "Renew subscription to keep my history →"; private static final String SET_GOALS = "Set Goals..."; + private static final String AUTO_PAUSE = "Auto-Pause..."; + private static final String ACTIVITY_REPORT = "Activity Report..."; public static ListPopup create(Project project, String totalTime, String projectTime) { ChangesActivityTracker tracker = @@ -65,8 +69,10 @@ public static ListPopup create(Project project, String totalTime, String project items.add(formatTodayVsYesterday(comparisonTask.getTodayVsYesterday())); items.add(formatThisWeekVsLastWeek(comparisonTask.getThisWeekVsLastWeek())); - // Add Set Goals action + // Add settings actions items.add(SET_GOALS); + items.add(AUTO_PAUSE); + items.add(ACTIVITY_REPORT); boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); if (ApiKeyLifecycle.isActivityDataStoppedBeingCollected()) { @@ -84,27 +90,36 @@ public boolean isSelectable(String value) { return WEB_DASHBOARD.equals(value) || SAVE_HISTORY.equals(value) || RENEW_SUBSCRIPTION.equals(value) - || SET_GOALS.equals(value); + || SET_GOALS.equals(value) + || AUTO_PAUSE.equals(value) + || ACTIVITY_REPORT.equals(value); } @Override public PopupStep onChosen(String selectedValue, boolean finalChoice) { if (WEB_DASHBOARD.equals(selectedValue)) { - Analytics.track( - AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "web_dashboard")); + Analytics.track(AnalyticsEventType.POPUP_WEB_DASHBOARD_CLICK); BrowserUtil.browse(HUB_UI_HOST); } else if (SAVE_HISTORY.equals(selectedValue)) { - Analytics.track( - AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "save_history")); + Analytics.track(AnalyticsEventType.POPUP_SAVE_HISTORY_CLICK); EnterApiKeyAction.showAction(); } else if (RENEW_SUBSCRIPTION.equals(selectedValue)) { - Analytics.track( - AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "renew_subscription")); + Analytics.track(AnalyticsEventType.POPUP_RENEW_SUBSCRIPTION_CLICK); BrowserUtil.browse(HUB_UI_HOST + "/payment"); } else if (SET_GOALS.equals(selectedValue)) { - Analytics.track( - AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "set_goals")); + Analytics.track(AnalyticsEventType.POPUP_SET_GOALS_CLICK); GoalSettingsDialog.showDialog(); + } else if (AUTO_PAUSE.equals(selectedValue)) { + Analytics.track(AnalyticsEventType.POPUP_AUTO_PAUSE_CLICK); + TrackingSettingsDialog.showDialog(); + } else if (ACTIVITY_REPORT.equals(selectedValue)) { + Analytics.track(AnalyticsEventType.POPUP_ACTIVITY_REPORT_CLICK); + ToolWindow toolWindow = + ToolWindowManager.getInstance(project) + .getToolWindow("CodeClocker Activity Report"); + if (toolWindow != null) { + toolWindow.show(); + } } return FINAL_CHOICE; } diff --git a/src/main/resources/META-INF/git-features.xml b/src/main/resources/META-INF/git-features.xml new file mode 100644 index 0000000..9914534 --- /dev/null +++ b/src/main/resources/META-INF/git-features.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 1c4facb..6404c0d 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -5,6 +5,7 @@ AndrewPasika com.intellij.modules.platform + Git4Idea messages.Bundle @@ -26,23 +27,15 @@ - - - - - - - - + order="before largeFileEncodingWidget"/> @@ -50,6 +43,11 @@ + + @@ -63,7 +61,7 @@ + description="Enter codeClocker API key">