> 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">