Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,7 +36,6 @@ public Object execute(
registerFocusListener();
startDataReportingTask();
startCheckingApiKeyStatus();
startTimeComparisonFetchTask();
startAnalyticsReportingTask();
ApiKeyPromptStartupActivity.showApiKeyDialog();
initializeTimerWidgets();
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Map<String, ProjectActivitySnapshot>> 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<String, Map<String, ProjectActivitySnapshot>> 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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.codeclocker.plugin.intellij.local;

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;
Expand All @@ -13,14 +13,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");

Expand All @@ -41,7 +43,7 @@ public void setHourKeyTimezone(String hourKeyTimezone) {
}

public boolean needsMigrationToUtc() {
return hourKeyTimezone == null || !TIMEZONE_UTC.equals(hourKeyTimezone);
return !TIMEZONE_UTC.equals(hourKeyTimezone);
}

public Map<String, Map<String, ProjectActivitySnapshot>> getHourlyActivity() {
Expand Down Expand Up @@ -111,24 +113,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<String, List<String>> 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<String> sortedDates = new ArrayList<>(hourKeysByDate.keySet());
sortedDates.sort(Comparator.reverseOrder()); // Descending

Set<String> 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<String> 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++;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<BranchActivityDto> 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<CommitDto> 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(
Expand All @@ -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 =
Expand Down
Loading
Loading