From 5520edc6ab10f74ab543328bdb0ec3f4d6d0c423 Mon Sep 17 00:00:00 2001 From: apasika Date: Wed, 14 Jan 2026 00:12:32 +0100 Subject: [PATCH 1/2] Local Trend Calculations --- CHANGELOG.md | 41 +++++- README.md | 2 +- gradle.properties | 2 +- .../plugin/intellij/ListenerRegistrator.java | 6 - .../intellij/apikey/ApiKeyPersistence.java | 8 - .../local/LocalActivityDataProvider.java | 41 +++++- .../intellij/local/LocalTrackerState.java | 54 +++++-- .../intellij/reporting/DataReportingTask.java | 44 +++++- .../reporting/TimeComparisonFetchTask.java | 138 ------------------ .../reporting/TimeComparisonHttpClient.java | 124 ---------------- .../reporting/TimeSpentSampleDto.java | 37 ++++- .../services/BranchActivityTracker.java | 62 ++++++-- .../toolwindow/BranchActivityPanel.java | 137 ++++++++++++++++- .../toolwindow/export/ExportDialog.java | 1 + .../intellij/widget/TimeTrackerPopup.java | 48 +++--- 15 files changed, 403 insertions(+), 342 deletions(-) delete mode 100644 src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java delete mode 100644 src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5e2a6..6db3059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,37 @@ ## [Unreleased] +## [1.7.0] - 2026-01-11 + +### Added + +- **Activity Report Info Banner** - New informational banner in Activity Report tool window: + - Shows days of activity history stored locally + - Displays local storage retention limit (14 days) + - Quick link to connect to Hub for unlimited history retention + - Adapts message based on Hub connection status + +### Changed + +- **Local Trend Calculations** - "Today vs Yesterday" and "This Week vs Last Week" comparisons now calculated from local data: + - Works offline without Hub connection + - Faster popup display (no network requests) + - Removed dependency on Hub API for trend data +- **Async Branch Tracking Initialization** - Branch tracker now initializes asynchronously: + - Faster IDE startup + - Prevents UI blocking when Git services are slow to initialize +- **Unified Hub Sync** - All activity data now sent in a single API payload: + - Time spent, VCS changes, branch activity, and commits synced together + - More efficient network usage + - Better data consistency + +### Removed + +- `TimeComparisonFetchTask` and `TimeComparisonHttpClient` - replaced by local calculations +- `DataAccessPolicy` - simplified data access architecture + +## [1.6.0] - 2026-01-08 + ### Added - **Activity Report Tool Window** - New IDE tool window accessible from the status bar popup showing detailed activity breakdown: @@ -12,17 +43,14 @@ - 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) @@ -32,17 +60,14 @@ - **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 @@ -146,7 +171,9 @@ - Support IntelliJ Platform 2024.3.5 -[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.5.2...HEAD +[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.7.0...HEAD +[1.7.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.6.0...v1.7.0 +[1.6.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.5.2...v1.6.0 [1.5.2]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.5.1...v1.5.2 [1.5.1]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.5.0...v1.5.1 [1.5.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.4.0...v1.5.0 diff --git a/README.md b/README.md index 9d5c702..acdadf7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Use it **locally (offline)** by default, or optionally sync to **[CodeClocker Hu - **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. +- **Status bar widget** - see today's tracked time, 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. diff --git a/gradle.properties b/gradle.properties index 378982b..36eeae0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.codeclocker pluginName = CodeClocker pluginRepositoryUrl = https://github.com/codeclocker/codeclocker-intellij-plugin # SemVer format -> https://semver.org -pluginVersion = 1.5.2 +pluginVersion = 1.7.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 252 diff --git a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java index 7ec1851..98754aa 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java +++ b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java @@ -7,7 +7,6 @@ import com.codeclocker.plugin.intellij.apikey.ApiKeyPromptStartupActivity; 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; @@ -37,7 +36,6 @@ public Object execute( registerFocusListener(); startDataReportingTask(); startCheckingApiKeyStatus(); - startTimeComparisonFetchTask(); startAnalyticsReportingTask(); ApiKeyPromptStartupActivity.showApiKeyDialog(); initializeTimerWidgets(); @@ -71,10 +69,6 @@ private static void startCheckingApiKeyStatus() { ApplicationManager.getApplication().getService(SubscriptionStateCheckerTask.class).schedule(); } - private static void startTimeComparisonFetchTask() { - ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class).schedule(); - } - private static void startAnalyticsReportingTask() { ApplicationManager.getApplication().getService(AnalyticsReportingTask.class).schedule(); } 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 a94d142..d2fcde2 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java +++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java @@ -3,7 +3,6 @@ import static org.apache.commons.lang3.StringUtils.isBlank; import com.codeclocker.plugin.intellij.reporting.DataReportingTask; -import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask; import com.intellij.ide.util.PropertiesComponent; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; @@ -44,13 +43,6 @@ private static void syncLocalDataToServer(String apiKey) { 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); } diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java index 41bde4d..664e8ab 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalActivityDataProvider.java @@ -92,11 +92,50 @@ public long getWeekTotalSeconds() { .sum(); } + /** + * Get total coded seconds for yesterday across all projects. + * + * @return total seconds coded yesterday in local timezone + */ + public long getYesterdayTotalSeconds() { + String yesterdayPrefix = LocalDate.now().minusDays(1).toString(); + Map> localData = getAllDataInLocalTimezone(); + + return localData.entrySet().stream() + .filter(entry -> entry.getKey().startsWith(yesterdayPrefix)) + .flatMap(entry -> entry.getValue().values().stream()) + .mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds) + .sum(); + } + + /** + * Get total coded seconds for last week (Monday to Sunday) across all projects. + * + * @return total seconds coded last week in local timezone + */ + public long getLastWeekTotalSeconds() { + LocalDate today = LocalDate.now(); + LocalDate startOfThisWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + LocalDate startOfLastWeek = startOfThisWeek.minusWeeks(1); + LocalDate endOfLastWeek = startOfThisWeek.minusDays(1); + Map> localData = getAllDataInLocalTimezone(); + + return localData.entrySet().stream() + .filter(entry -> isInDateRange(entry.getKey(), startOfLastWeek, endOfLastWeek)) + .flatMap(entry -> entry.getValue().values().stream()) + .mapToLong(ProjectActivitySnapshot::getCodedTimeSeconds) + .sum(); + } + private boolean isInWeek(String hourKey, LocalDate weekStart, LocalDate today) { + return isInDateRange(hourKey, weekStart, today); + } + + private boolean isInDateRange(String hourKey, LocalDate start, LocalDate end) { try { String dateStr = hourKey.substring(0, 10); // "yyyy-MM-dd" LocalDate date = LocalDate.parse(dateStr); - return !date.isBefore(weekStart) && !date.isAfter(today); + return !date.isBefore(start) && !date.isAfter(end); } catch (Exception e) { return false; } 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 7544221..47d45d6 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -13,14 +14,16 @@ /** * State class for local persistence of tracked time and VCS changes. Structure: datetime - * (YYYY-MM-DD-HH in UTC) -> 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 30 + * coding sessions (days with activity). */ public class LocalTrackerState { public static final String TIMEZONE_UTC = "UTC"; - private static final int RETENTION_DAYS = 14; + /** Maximum number of coding sessions (days with activity) to retain locally. */ + public static final int MAX_SESSIONS = 30; + private static final DateTimeFormatter DATETIME_HOUR_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); @@ -41,7 +44,7 @@ public void setHourKeyTimezone(String hourKeyTimezone) { } public boolean needsMigrationToUtc() { - return hourKeyTimezone == null || !TIMEZONE_UTC.equals(hourKeyTimezone); + return !TIMEZONE_UTC.equals(hourKeyTimezone); } public Map> getHourlyActivity() { @@ -111,24 +114,45 @@ public void mergeProject( }); } - /** Removes entries older than 2 weeks. Returns number of hour slots removed. */ + /** + * Removes entries beyond the maximum session limit. Keeps only the most recent MAX_SESSIONS days + * (days with coding activity). Returns number of hour slots removed. + */ public int cleanupOldEntries() { - LocalDateTime cutoffDateTime = LocalDateTime.now().minusDays(RETENTION_DAYS); - int removedCount = 0; + // Group hourKeys by date to find unique sessions + Map> hourKeysByDate = new HashMap<>(); + for (String hourKey : hourlyActivity.keySet()) { + if (hourKey != null && hourKey.length() >= 10) { + String date = hourKey.substring(0, 10); // Extract yyyy-MM-dd + hourKeysByDate.computeIfAbsent(date, k -> new ArrayList<>()).add(hourKey); + } + } + // If we have MAX_SESSIONS or fewer, no cleanup needed + if (hourKeysByDate.size() <= MAX_SESSIONS) { + return 0; + } + + // Sort dates descending (newest first) and find dates to remove + List sortedDates = new ArrayList<>(hourKeysByDate.keySet()); + sortedDates.sort(Comparator.reverseOrder()); // Descending + + Set datesToRemove = new HashSet<>(); + for (int i = MAX_SESSIONS; i < sortedDates.size(); i++) { + datesToRemove.add(sortedDates.get(i)); + } + + // Remove all hourKeys for dates beyond the limit + int removedCount = 0; Iterator iterator = hourlyActivity.keySet().iterator(); while (iterator.hasNext()) { - String datetimeStr = iterator.next(); - try { - LocalDateTime entryDateTime = LocalDateTime.parse(datetimeStr, DATETIME_HOUR_FORMATTER); - if (entryDateTime.isBefore(cutoffDateTime)) { + String hourKey = iterator.next(); + if (hourKey != null && hourKey.length() >= 10) { + String date = hourKey.substring(0, 10); + if (datesToRemove.contains(date)) { iterator.remove(); removedCount++; } - } catch (Exception e) { - // Invalid format, remove the entry - iterator.remove(); - removedCount++; } } 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 1e89cb9..61e4072 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java @@ -13,6 +13,8 @@ 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.reporting.TimeSpentSampleDto.BranchActivityDto; +import com.codeclocker.plugin.intellij.reporting.TimeSpentSampleDto.CommitDto; import com.codeclocker.plugin.intellij.services.BranchActivityTracker; import com.codeclocker.plugin.intellij.services.ChangesSample; import com.codeclocker.plugin.intellij.services.CommitActivityTracker; @@ -245,8 +247,38 @@ public void syncLocalDataToServer(String apiKey) { String projectName = projectEntry.getKey(); ProjectActivitySnapshot snapshot = projectEntry.getValue(); - // Keep time spent data per hour per project (hourKey is already in UTC) - if (snapshot.getCodedTimeSeconds() > 0) { + // Convert branch activity to DTOs + List branchActivityDtos = null; + if (snapshot.getBranchActivity() != null && !snapshot.getBranchActivity().isEmpty()) { + branchActivityDtos = + snapshot.getBranchActivity().stream() + .map(ba -> new BranchActivityDto(ba.getBranchName(), ba.getActiveSeconds())) + .toList(); + } + + // Convert commits to DTOs + List commitDtos = null; + if (snapshot.getCommits() != null && !snapshot.getCommits().isEmpty()) { + commitDtos = + snapshot.getCommits().stream() + .map( + c -> + new CommitDto( + c.getHash(), + c.getMessage(), + c.getAuthor(), + c.getTimestamp(), + c.getChangedFilesCount(), + c.getBranch())) + .toList(); + } + + // Include all data in the time spent DTO (unified sync) + if (snapshot.getCodedTimeSeconds() > 0 + || snapshot.getAdditions() > 0 + || snapshot.getRemovals() > 0 + || (branchActivityDtos != null && !branchActivityDtos.isEmpty()) + || (commitDtos != null && !commitDtos.isEmpty())) { timeSpentByHour .computeIfAbsent(hourKey, k -> new HashMap<>()) .put( @@ -255,10 +287,14 @@ public void syncLocalDataToServer(String apiKey) { snapshot.getRecordId(), hourKey, snapshot.getCodedTimeSeconds(), - snapshot.getCodedTimeSeconds())); + snapshot.getCodedTimeSeconds(), + snapshot.getAdditions(), + snapshot.getRemovals(), + branchActivityDtos, + commitDtos)); } - // Aggregate VCS changes per project (with hour in filename for tracking) + // Also send VCS changes via legacy endpoint for backward compatibility if (snapshot.getAdditions() > 0 || snapshot.getRemovals() > 0) { String syntheticFileName = "local-sync-" + hourKey; ChangesSampleDto changesDto = diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java deleted file mode 100644 index f672b56..0000000 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java +++ /dev/null @@ -1,138 +0,0 @@ -package com.codeclocker.plugin.intellij.reporting; - -import static com.codeclocker.plugin.intellij.ScheduledExecutor.EXECUTOR; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.apache.commons.lang3.StringUtils.isNotBlank; - -import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle; -import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimeComparisonResponse; -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; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.TemporalAdjusters; -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); - private static final int FETCH_INTERVAL_SECONDS = 60; - - private final TimeComparisonHttpClient httpClient; - - private final AtomicReference todayVsYesterday = new AtomicReference<>(); - private final AtomicReference thisWeekVsLastWeek = - new AtomicReference<>(); - private ScheduledFuture task; - - public TimeComparisonFetchTask() { - this.httpClient = new TimeComparisonHttpClient(); - } - - public void schedule() { - if (task != null && !task.isCancelled()) { - return; - } - - task = EXECUTOR.scheduleWithFixedDelay(this::fetchData, 0, FETCH_INTERVAL_SECONDS, SECONDS); - } - - private void fetchData() { - try { - String apiKey = ApiKeyLifecycle.getActiveApiKey(); - if (!isNotBlank(apiKey)) { - LOG.debug("No API key available, skipping time comparison fetch"); - return; - } - - ZoneId timeZone = ZoneId.systemDefault(); - fetchTodayVsYesterday(apiKey, timeZone); - fetchThisWeekVsLastWeek(apiKey, timeZone); - } catch (Exception ex) { - LOG.debug("Error fetching time comparison data: {}", ex.getMessage()); - } - } - - /** - * Triggers an immediate refetch of time comparison data. Called after local data is synced to the - * server to update trends. - */ - public void refetch() { - LOG.debug("Triggering immediate refetch of time comparison data"); - fetchData(); - } - - private void fetchTodayVsYesterday(String apiKey, ZoneId timeZone) { - LocalDate today = LocalDate.now(timeZone); - LocalDate yesterday = today.minusDays(1); - - Instant todayStart = toStartOfDay(today, timeZone); - Instant todayEnd = Instant.now(); - Instant yesterdayStart = toStartOfDay(yesterday, timeZone); - Instant yesterdayEnd = toEndOfDay(yesterday, timeZone); - - TimeComparisonResponse response = - httpClient.fetchTimeComparison(apiKey, todayStart, todayEnd, yesterdayStart, yesterdayEnd); - - if (response.isSuccess()) { - todayVsYesterday.set(response.getComparison()); - LOG.debug("Updated today vs yesterday comparison: {}", todayVsYesterday.get()); - } - } - - private void fetchThisWeekVsLastWeek(String apiKey, ZoneId timeZone) { - LocalDate today = LocalDate.now(timeZone); - LocalDate startOfThisWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); - LocalDate startOfLastWeek = startOfThisWeek.minusWeeks(1); - LocalDate endOfLastWeek = startOfThisWeek.minusDays(1); - - Instant thisWeekStart = toStartOfDay(startOfThisWeek, timeZone); - Instant thisWeekEnd = Instant.now(); - Instant lastWeekStart = toStartOfDay(startOfLastWeek, timeZone); - Instant lastWeekEnd = toEndOfDay(endOfLastWeek, timeZone); - - TimeComparisonResponse response = - httpClient.fetchTimeComparison( - apiKey, thisWeekStart, thisWeekEnd, lastWeekStart, lastWeekEnd); - - if (response.isSuccess()) { - thisWeekVsLastWeek.set(response.getComparison()); - LOG.debug("Updated this week vs last week comparison: {}", thisWeekVsLastWeek.get()); - } - } - - private static Instant toStartOfDay(LocalDate date, ZoneId timeZone) { - return ZonedDateTime.of(date.atStartOfDay(), timeZone).toInstant(); - } - - private static Instant toEndOfDay(LocalDate date, ZoneId timeZone) { - return ZonedDateTime.of(date.atTime(23, 59, 59), timeZone).toInstant(); - } - - public TimePeriodComparisonDto getTodayVsYesterday() { - return todayVsYesterday.get(); - } - - public TimePeriodComparisonDto getThisWeekVsLastWeek() { - return thisWeekVsLastWeek.get(); - } - - public static TimeComparisonFetchTask getInstance() { - return ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class); - } - - @Override - public void dispose() { - if (task != null) { - task.cancel(false); - } - } -} diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java deleted file mode 100644 index e32cbe6..0000000 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java +++ /dev/null @@ -1,124 +0,0 @@ -package com.codeclocker.plugin.intellij.reporting; - -import static com.codeclocker.plugin.intellij.HubHost.HUB_API_HOST; -import static com.codeclocker.plugin.intellij.JsonMapper.OBJECT_MAPPER; -import static com.codeclocker.plugin.intellij.Timeouts.CONNECT_TIMEOUT; -import static com.codeclocker.plugin.intellij.Timeouts.READ_TIMEOUT; - -import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.util.io.HttpRequests; -import java.io.IOException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Instant; - -public class TimeComparisonHttpClient { - - private static final Logger LOG = Logger.getInstance(TimeComparisonHttpClient.class); - - private final ApiKeyLifecycle apiKeyLifecycle; - - public TimeComparisonHttpClient() { - this.apiKeyLifecycle = ApplicationManager.getApplication().getService(ApiKeyLifecycle.class); - } - - public TimeComparisonResponse fetchTimeComparison( - String apiKey, - Instant currentFrom, - Instant currentTo, - Instant previousFrom, - Instant previousTo) { - try { - String url = buildUrl(currentFrom, currentTo, previousFrom, previousTo); - LOG.debug("Fetching time comparison from: {}", url); - - String response = - HttpRequests.request(url) - .connectTimeout(CONNECT_TIMEOUT) - .readTimeout(READ_TIMEOUT) - .tuner(connection -> connection.setRequestProperty("X-codeclocker-api-key", apiKey)) - .readString(); - - LOG.debug("Received response: {}", response); - - if (response.contains("Activity data stopped being collected")) { - apiKeyLifecycle.processHubErrorResponse(response); - return TimeComparisonResponse.subscriptionExpired(); - } - - TimePeriodComparisonDto dto = - OBJECT_MAPPER.readValue(response, TimePeriodComparisonDto.class); - return TimeComparisonResponse.success(dto); - } catch (IOException ex) { - LOG.warn("Error fetching time comparison: " + ex.getMessage(), ex); - return TimeComparisonResponse.error(); - } - } - - private static String buildUrl( - Instant currentFrom, Instant currentTo, Instant previousFrom, Instant previousTo) { - return HUB_API_HOST - + "/api/v1/plugin/time-comparison?" - + "currentFrom=" - + encode(currentFrom.toString()) - + "¤tTo=" - + encode(currentTo.toString()) - + "&previousFrom=" - + encode(previousFrom.toString()) - + "&previousTo=" - + encode(previousTo.toString()); - } - - private static String encode(String value) { - return URLEncoder.encode(value, StandardCharsets.UTF_8); - } - - public record TimePeriodComparisonDto( - long currentPeriodSeconds, - long previousPeriodSeconds, - long differenceSeconds, - int percentageChange) {} - - public static class TimeComparisonResponse { - private final TimePeriodComparisonDto comparison; - private final boolean subscriptionExpired; - private final boolean error; - - private TimeComparisonResponse( - TimePeriodComparisonDto comparison, boolean subscriptionExpired, boolean error) { - this.comparison = comparison; - this.subscriptionExpired = subscriptionExpired; - this.error = error; - } - - public static TimeComparisonResponse success(TimePeriodComparisonDto comparison) { - return new TimeComparisonResponse(comparison, false, false); - } - - public static TimeComparisonResponse subscriptionExpired() { - return new TimeComparisonResponse(null, true, false); - } - - public static TimeComparisonResponse error() { - return new TimeComparisonResponse(null, false, true); - } - - public TimePeriodComparisonDto getComparison() { - return comparison; - } - - public boolean isSubscriptionExpired() { - return subscriptionExpired; - } - - public boolean isError() { - return error; - } - - public boolean isSuccess() { - return !subscriptionExpired && !error && comparison != null; - } - } -} 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 f734be1..3df485a 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeSpentSampleDto.java @@ -1,13 +1,46 @@ package com.codeclocker.plugin.intellij.reporting; +import java.util.List; + /** - * DTO for time spent sample sent to the hub. + * DTO for time spent sample sent to the hub. Contains all activity data for a project in an hour + * bucket. * * @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) + * @param additions VCS lines added (nullable for backward compatibility) + * @param removals VCS lines removed (nullable for backward compatibility) + * @param branchActivity list of branch activity records (nullable for backward compatibility) + * @param commits list of commit records (nullable for backward compatibility) */ public record TimeSpentSampleDto( - String recordId, String hourKey, long deltaSeconds, long totalHourSeconds) {} + String recordId, + String hourKey, + long deltaSeconds, + long totalHourSeconds, + Long additions, + Long removals, + List branchActivity, + List commits) { + + /** Backward compatible constructor without new fields. */ + public TimeSpentSampleDto( + String recordId, String hourKey, long deltaSeconds, long totalHourSeconds) { + this(recordId, hourKey, deltaSeconds, totalHourSeconds, null, null, null, null); + } + + /** DTO for branch activity within an hour. */ + public record BranchActivityDto(String branchName, long activeSeconds) {} + + /** DTO for commit record. */ + public record CommitDto( + String hash, + String message, + String author, + long timestamp, + int changedFilesCount, + String branch) {} +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java b/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java index 9fc5b1e..acc522c 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java +++ b/src/main/java/com/codeclocker/plugin/intellij/services/BranchActivityTracker.java @@ -1,5 +1,6 @@ package com.codeclocker.plugin.intellij.services; +import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.components.Service; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; @@ -131,7 +132,8 @@ public Map> drainAllBranchActivity(String projectName) } /** - * Initialize branch tracking for a project by reading current branch from git. + * Initialize branch tracking for a project by reading current branch from git. Runs + * asynchronously to avoid blocking IDE startup. * * @param project the IntelliJ project */ @@ -140,21 +142,49 @@ public void initializeFromGit(Project project) { 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); - } + // Run asynchronously to avoid blocking startup + ApplicationManager.getApplication() + .executeOnPooledThread( + () -> { + try { + if (project.isDisposed()) { + return; + } + + // Read action needed to access Git services safely + ApplicationManager.getApplication() + .runReadAction( + () -> { + if (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.debug( + "Failed to initialize branch tracking for " + project.getName(), e); + } + }); + } catch (Exception e) { + LOG.debug("Failed to initialize branch tracking for " + project.getName(), e); + } + }); } private String getBranchName(GitRepository repo) { diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java index 7540956..603ac28 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java @@ -1,12 +1,20 @@ package com.codeclocker.plugin.intellij.toolwindow; +import static com.codeclocker.plugin.intellij.HubHost.HUB_UI_HOST; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle; +import com.codeclocker.plugin.intellij.apikey.ApiKeyPersistence; +import com.codeclocker.plugin.intellij.apikey.EnterApiKeyAction; import com.codeclocker.plugin.intellij.local.CommitRecord; import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; +import com.codeclocker.plugin.intellij.local.LocalTrackerState; 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.ide.BrowserUtil; import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.ActionToolbar; @@ -22,9 +30,13 @@ import com.intellij.openapi.ui.ComboBox; import com.intellij.openapi.ui.Messages; import com.intellij.openapi.vfs.VirtualFileWrapper; +import com.intellij.ui.HyperlinkLabel; import com.intellij.ui.components.JBScrollPane; import com.intellij.ui.treeStructure.treetable.TreeTable; +import com.intellij.util.ui.JBUI; import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.FlowLayout; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -61,6 +73,9 @@ public class BranchActivityPanel extends JPanel implements Disposable { private final ActivityTreeTableModel treeTableModel; private final ComboBox projectComboBox; private final javax.swing.Timer autoRefreshTimer; + private final JPanel infoBanner; + private final javax.swing.JLabel bannerMessageLabel; + private final HyperlinkLabel bannerLink; private String selectedProject; @@ -70,12 +85,18 @@ public BranchActivityPanel(Project project) { this.treeTableModel = new ActivityTreeTableModel(); this.treeTable = new TreeTable(treeTableModel); this.projectComboBox = new ComboBox<>(); + this.bannerMessageLabel = new javax.swing.JLabel(); + this.bannerLink = new HyperlinkLabel(); + this.infoBanner = createInfoBanner(); setLayout(new BorderLayout()); - // Create toolbar + // Create header with toolbar and info banner + JPanel headerPanel = new JPanel(new BorderLayout()); JPanel toolbarPanel = createToolbarPanel(); - add(toolbarPanel, BorderLayout.NORTH); + headerPanel.add(toolbarPanel, BorderLayout.NORTH); + headerPanel.add(infoBanner, BorderLayout.SOUTH); + add(headerPanel, BorderLayout.NORTH); // Configure tree table configureTreeTable(); @@ -178,6 +199,115 @@ public void actionPerformed(@NotNull AnActionEvent e) { return toolbarPanel; } + private JPanel createInfoBanner() { + JPanel banner = new JPanel(new FlowLayout(FlowLayout.LEFT, 8, 4)); + banner.setBackground(new Color(230, 243, 255)); // Light blue + banner.setBorder(JBUI.Borders.customLine(new Color(100, 149, 237), 0, 0, 1, 0)); + + javax.swing.JLabel infoIcon = new javax.swing.JLabel(AllIcons.General.Information); + banner.add(infoIcon); + banner.add(bannerMessageLabel); + banner.add(bannerLink); + + // Set up click handler that checks current state + bannerLink.addHyperlinkListener( + e -> { + if (javax.swing.event.HyperlinkEvent.EventType.ACTIVATED.equals(e.getEventType())) { + try { + boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); + boolean subscriptionExpired = ApiKeyLifecycle.isActivityDataStoppedBeingCollected(); + + if (hasApiKey && subscriptionExpired) { + BrowserUtil.browse(HUB_UI_HOST + "/payment"); + } else { + EnterApiKeyAction.showAction(); + } + } catch (Exception ex) { + LOG.debug("Failed to handle banner link click", ex); + EnterApiKeyAction.showAction(); + } + } + }); + + banner.setVisible(false); // Initially hidden + return banner; + } + + private void updateInfoBanner() { + // Run on EDT to avoid threading issues, and use invokeLater to avoid blocking + ApplicationManager.getApplication() + .invokeLater( + () -> { + try { + boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); + boolean subscriptionExpired = ApiKeyLifecycle.isActivityDataStoppedBeingCollected(); + boolean hasActiveSubscription = hasApiKey && !subscriptionExpired; + + if (hasActiveSubscription) { + // Connected with active subscription - hide banner + infoBanner.setVisible(false); + return; + } + + // Calculate days of history + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + int daysOfHistory = calculateDaysOfHistory(dataProvider); + + int maxDays = LocalTrackerState.MAX_SESSIONS; + String message; + if (daysOfHistory >= maxDays) { + message = + String.format( + "You have %d days of history. Older data is being rotated.", maxDays); + } else if (daysOfHistory > 0) { + message = + String.format( + "You have %d day%s of history. Local storage keeps %d days.", + daysOfHistory, daysOfHistory == 1 ? "" : "s", maxDays); + } else { + message = "Start coding to build your activity history."; + } + + bannerMessageLabel.setText(message); + bannerLink.setHyperlinkText( + hasApiKey && subscriptionExpired + ? "Renew subscription to keep data forever" + : "Connect to Hub to keep data forever"); + infoBanner.setVisible(true); + } catch (Exception e) { + // Services may not be ready during early initialization + LOG.debug("Failed to update info banner, services may not be ready", e); + infoBanner.setVisible(false); + } + }); + } + + /** + * Count unique days with coding activity (sessions). A session is a day where the user coded. + */ + private int calculateDaysOfHistory(LocalActivityDataProvider dataProvider) { + if (dataProvider == null) { + return 0; + } + + Map> data = + dataProvider.getAllDataInLocalTimezone(); + if (data == null || data.isEmpty()) { + return 0; + } + + // Count unique dates (hourKey format: yyyy-MM-dd-HH, extract first 10 chars for date) + Set uniqueDates = new HashSet<>(); + for (String hourKey : data.keySet()) { + if (hourKey != null && hourKey.length() >= 10) { + uniqueDates.add(hourKey.substring(0, 10)); + } + } + + return uniqueDates.size(); + } + private void configureTreeTable() { treeTable.setRootVisible(false); treeTable.setRowHeight(25); @@ -205,6 +335,9 @@ public void refreshData() { return; } + // Update info banner based on Hub connection status + updateInfoBanner(); + // Save expanded state before refresh Set expandedNodes = saveExpandedState(); 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 index 72c75ac..e46472b 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ExportDialog.java +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/export/ExportDialog.java @@ -73,6 +73,7 @@ protected void doOKAction() { setErrorText("'From' date must be before or equal to 'To' date"); return; } + super.doOKAction(); } } 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 d6b6eb1..28e8f78 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java @@ -13,8 +13,7 @@ import com.codeclocker.plugin.intellij.goal.GoalProgress; import com.codeclocker.plugin.intellij.goal.GoalService; import com.codeclocker.plugin.intellij.goal.GoalSettingsDialog; -import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask; -import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimePeriodComparisonDto; +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; import com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker; import com.codeclocker.plugin.intellij.services.vcs.ProjectChangesCounters; import com.codeclocker.plugin.intellij.tracking.TrackingSettingsDialog; @@ -35,8 +34,8 @@ public class TimeTrackerPopup { private static final String WEB_DASHBOARD = "My Dashboard →"; - 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 SAVE_HISTORY = "Save my data →"; + private static final String RENEW_SUBSCRIPTION = "Renew subscription to keep data forever →"; private static final String SET_GOALS = "Set Goals..."; private static final String AUTO_PAUSE = "Auto-Pause..."; private static final String ACTIVITY_REPORT = "Activity Report..."; @@ -46,7 +45,8 @@ public static ListPopup create(Project project, String totalTime, String project ApplicationManager.getApplication().getService(ChangesActivityTracker.class); ProjectChangesCounters projectChanges = tracker.getProjectChanges(project.getName()); - TimeComparisonFetchTask comparisonTask = TimeComparisonFetchTask.getInstance(); + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); GoalService goalService = ApplicationManager.getApplication().getService(GoalService.class); List items = new ArrayList<>(); @@ -65,9 +65,9 @@ public static ListPopup create(Project project, String totalTime, String project items.add("Total: " + getFormattedVcsChanges()); items.add(project.getName() + ": " + formatProjectVcsChanges(projectChanges)); - // Trends - items.add(formatTodayVsYesterday(comparisonTask.getTodayVsYesterday())); - items.add(formatThisWeekVsLastWeek(comparisonTask.getThisWeekVsLastWeek())); + // Trends (calculated on-demand from local data) + items.add(formatTodayVsYesterday(dataProvider)); + items.add(formatThisWeekVsLastWeek(dataProvider)); // Add settings actions items.add(SET_GOALS); @@ -176,24 +176,38 @@ private static String formatProjectVcsChanges(ProjectChangesCounters changes) { return String.format("+%d / -%d", changes.additions().get(), changes.removals().get()); } - private static String formatTodayVsYesterday(TimePeriodComparisonDto comparison) { - if (comparison == null) { + private static String formatTodayVsYesterday(LocalActivityDataProvider dataProvider) { + if (dataProvider == null) { return "Today vs. Yesterday: --"; } + long todaySeconds = dataProvider.getTodayTotalSeconds(); + long yesterdaySeconds = dataProvider.getYesterdayTotalSeconds(); + long diff = todaySeconds - yesterdaySeconds; + int percentage = calculatePercentageChange(todaySeconds, yesterdaySeconds); + return String.format( - "Today vs. Yesterday: %s / %s", - formatTimeDifference(comparison.differenceSeconds()), - formatPercentage(comparison.percentageChange())); + "Today vs. Yesterday: %s / %s", formatTimeDifference(diff), formatPercentage(percentage)); } - private static String formatThisWeekVsLastWeek(TimePeriodComparisonDto comparison) { - if (comparison == null) { + private static String formatThisWeekVsLastWeek(LocalActivityDataProvider dataProvider) { + if (dataProvider == null) { return "This week vs. Last week: --"; } + long thisWeekSeconds = dataProvider.getWeekTotalSeconds(); + long lastWeekSeconds = dataProvider.getLastWeekTotalSeconds(); + long diff = thisWeekSeconds - lastWeekSeconds; + int percentage = calculatePercentageChange(thisWeekSeconds, lastWeekSeconds); + return String.format( "This week vs. Last week: %s / %s", - formatTimeDifference(comparison.differenceSeconds()), - formatPercentage(comparison.percentageChange())); + formatTimeDifference(diff), formatPercentage(percentage)); + } + + private static int calculatePercentageChange(long current, long previous) { + if (previous == 0) { + return current > 0 ? 100 : 0; + } + return (int) Math.round(((double) (current - previous) / previous) * 100); } private static String formatTimeDifference(long diffSeconds) { From c3bef7460566b6e36a656da7afdd432023483bc8 Mon Sep 17 00:00:00 2001 From: apasika Date: Wed, 14 Jan 2026 00:14:28 +0100 Subject: [PATCH 2/2] Local Trend Calculations --- .../plugin/intellij/local/LocalTrackerState.java | 1 - .../plugin/intellij/toolwindow/BranchActivityPanel.java | 6 ++---- 2 files changed, 2 insertions(+), 5 deletions(-) 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 47d45d6..26a67c9 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java +++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java @@ -1,6 +1,5 @@ package com.codeclocker.plugin.intellij.local; -import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java index 603ac28..fce47b4 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityPanel.java @@ -272,7 +272,7 @@ private void updateInfoBanner() { bannerMessageLabel.setText(message); bannerLink.setHyperlinkText( hasApiKey && subscriptionExpired - ? "Renew subscription to keep data forever" + ? "Renew subscription to keep your data forever" : "Connect to Hub to keep data forever"); infoBanner.setVisible(true); } catch (Exception e) { @@ -283,9 +283,7 @@ private void updateInfoBanner() { }); } - /** - * Count unique days with coding activity (sessions). A session is a day where the user coded. - */ + /** Count unique days with coding activity (sessions). A session is a day where the user coded. */ private int calculateDaysOfHistory(LocalActivityDataProvider dataProvider) { if (dataProvider == null) { return 0;