diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63982fb..00624a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,10 @@
# CodeClocker Changelog
+## [1.3.0] - 2025-12-15
+
+- Save activity data to local storage to survive IDE restarts
+
## [1.2.3] - 2025-11-29
- Reset VCS changes at midnight in IDE status bar
@@ -72,7 +76,8 @@
- Support IntelliJ Platform 2024.3.5
-[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.2.2...HEAD
+[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.2.3...HEAD
+[1.2.3]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.2.2...v1.2.3
[1.2.2]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.1.0...v1.2.0
diff --git a/gradle.properties b/gradle.properties
index 95a93e2..9f51dcc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,7 +4,7 @@ pluginGroup = com.codeclocker
pluginName = CodeClocker
pluginRepositoryUrl = https://github.com/codeclocker/codeclocker-intellij-plugin
# SemVer format -> https://semver.org
-pluginVersion = 1.2.3
+pluginVersion = 1.3.0
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
pluginSinceBuild = 242
diff --git a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java
index 275d815..de1b164 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/ListenerRegistrator.java
@@ -3,9 +3,11 @@
import static com.codeclocker.plugin.intellij.widget.TimeTrackerInitializer.initializeTimerWidgets;
import static java.awt.AWTEvent.FOCUS_EVENT_MASK;
+import com.codeclocker.plugin.intellij.analytics.AnalyticsReportingTask;
import com.codeclocker.plugin.intellij.apikey.ApiKeyPromptStartupActivity;
import com.codeclocker.plugin.intellij.listeners.FocusListener;
import com.codeclocker.plugin.intellij.reporting.DataReportingTask;
+import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask;
import com.codeclocker.plugin.intellij.subscription.SubscriptionStateCheckerTask;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.project.Project;
@@ -34,6 +36,8 @@ public Object execute(
registerFocusListener();
startDataReportingTask();
startCheckingApiKeyStatus();
+ startTimeComparisonFetchTask();
+ startAnalyticsReportingTask();
ApiKeyPromptStartupActivity.showApiKeyDialog();
initializeTimerWidgets();
@@ -48,11 +52,18 @@ private static void registerFocusListener() {
}
private static void startDataReportingTask() {
- DataReportingTask task = new DataReportingTask();
- task.schedule();
+ ApplicationManager.getApplication().getService(DataReportingTask.class).schedule();
}
private static void startCheckingApiKeyStatus() {
ApplicationManager.getApplication().getService(SubscriptionStateCheckerTask.class).schedule();
}
+
+ private static void startTimeComparisonFetchTask() {
+ ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class).schedule();
+ }
+
+ private static void startAnalyticsReportingTask() {
+ ApplicationManager.getApplication().getService(AnalyticsReportingTask.class).schedule();
+ }
}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java
new file mode 100644
index 0000000..820f860
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/Analytics.java
@@ -0,0 +1,50 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Simple facade for tracking analytics events from anywhere in the plugin. Usage:
+ *
+ *
+ * Analytics.track(AnalyticsEventType.WIDGET_CLICK);
+ * Analytics.track(AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "pause"));
+ *
+ */
+public final class Analytics {
+
+ private static final Logger LOG = Logger.getInstance(Analytics.class);
+
+ private Analytics() {}
+
+ /**
+ * Tracks an analytics event.
+ *
+ * @param eventType The type of event to track (use constants from {@link AnalyticsEventType})
+ */
+ public static void track(@NotNull String eventType) {
+ track(eventType, Map.of());
+ }
+
+ /**
+ * Tracks an analytics event with properties.
+ *
+ * @param eventType The type of event to track
+ * @param properties Additional event-specific properties
+ */
+ public static void track(@NotNull String eventType, @NotNull Map properties) {
+ try {
+ AnalyticsReportingTask task =
+ ApplicationManager.getApplication().getService(AnalyticsReportingTask.class);
+ if (task != null) {
+ task.track(eventType, properties);
+ } else {
+ LOG.debug("AnalyticsReportingTask not available, event not tracked: " + eventType);
+ }
+ } catch (Exception ex) {
+ LOG.debug("Failed to track event: " + eventType, ex);
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEvent.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEvent.java
new file mode 100644
index 0000000..1b1c693
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEvent.java
@@ -0,0 +1,21 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import java.util.Map;
+
+/**
+ * Represents a single analytics event.
+ *
+ * @param eventType The type of event (use constants from {@link AnalyticsEventType})
+ * @param timestamp Unix timestamp in milliseconds when the event occurred
+ * @param properties Additional event-specific properties
+ */
+public record AnalyticsEvent(String eventType, long timestamp, Map properties) {
+
+ public static AnalyticsEvent of(String eventType) {
+ return new AnalyticsEvent(eventType, System.currentTimeMillis(), Map.of());
+ }
+
+ public static AnalyticsEvent of(String eventType, Map properties) {
+ return new AnalyticsEvent(eventType, System.currentTimeMillis(), properties);
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java
new file mode 100644
index 0000000..73b43f6
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java
@@ -0,0 +1,32 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+/** Constants for analytics event types. Using constants instead of enum for extensibility. */
+public final class AnalyticsEventType {
+
+ private AnalyticsEventType() {}
+
+ // Widget events
+ public static final String STATUS_BAR_WIDGET_CLICK = "status_bar_widget_click";
+ public static final String WIDGET_POPUP_OPEN = "widget_popup_open";
+ public static final String WIDGET_POPUP_ACTION = "widget_popup_action";
+
+ // API key events
+ public static final String API_KEY_ENTERED = "api_key_entered";
+ public static final String API_KEY_CLEARED = "api_key_cleared";
+ public static final String API_KEY_DIALOG_OPENED = "api_key_dialog_opened";
+ public static final String API_KEY_DIALOG_CANCELLED = "api_key_dialog_cancelled";
+
+ // Plugin lifecycle events
+ public static final String PLUGIN_STARTED = "plugin_started";
+ public static final String PLUGIN_STOPPED = "plugin_stopped";
+
+ // Action events
+ public static final String ACTION_SHOW_STATISTICS = "action_show_statistics";
+
+ // Feature usage events
+ public static final String FEATURE_LOCAL_STORAGE_SYNC = "feature_local_storage_sync";
+
+ // Error events
+ public static final String ERROR_DATA_REPORT_FAILED = "error_data_report_failed";
+ public static final String ERROR_API_REQUEST_FAILED = "error_api_request_failed";
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsHttpClient.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsHttpClient.java
new file mode 100644
index 0000000..c08d3e5
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsHttpClient.java
@@ -0,0 +1,62 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import static com.codeclocker.plugin.intellij.HubHost.HUB_API_HOST;
+import static com.codeclocker.plugin.intellij.JsonMapper.OBJECT_MAPPER;
+import static com.codeclocker.plugin.intellij.Timeouts.CONNECT_TIMEOUT;
+import static com.codeclocker.plugin.intellij.Timeouts.READ_TIMEOUT;
+import static com.intellij.util.io.HttpRequests.JSON_CONTENT_TYPE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle;
+import com.codeclocker.plugin.intellij.reporting.SentStatus;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.util.io.HttpRequests;
+import java.io.IOException;
+
+/** HTTP client for sending analytics reports to the backend. */
+public class AnalyticsHttpClient {
+
+ private static final Logger LOG = Logger.getInstance(AnalyticsHttpClient.class);
+
+ private static final String ANALYTICS_ENDPOINT = "/api/v1/analytics/events";
+
+ /**
+ * Sends an analytics report to the backend.
+ *
+ * @param report The analytics report to send
+ * @return SentStatus indicating success or failure
+ */
+ public SentStatus sendAnalyticsReport(AnalyticsReportDto report) {
+ try {
+ String jsonData = OBJECT_MAPPER.writeValueAsString(report);
+ LOG.debug("Sending analytics report with " + report.events().size() + " events");
+
+ String apiKey = ApiKeyLifecycle.getActiveApiKey();
+
+ HttpRequests.post(HUB_API_HOST + ANALYTICS_ENDPOINT, JSON_CONTENT_TYPE)
+ .connectTimeout(CONNECT_TIMEOUT)
+ .readTimeout(READ_TIMEOUT)
+ .tuner(
+ connection -> {
+ if (apiKey != null && !apiKey.isBlank()) {
+ connection.setRequestProperty("X-codeclocker-api-key", apiKey);
+ }
+ })
+ .connect(
+ request -> {
+ request.write(jsonData.getBytes(UTF_8));
+ return request.readString();
+ });
+
+ LOG.debug("Analytics report sent successfully");
+ return SentStatus.OK;
+ } catch (JsonProcessingException ex) {
+ LOG.warn("Failed to serialize analytics report: " + ex.getMessage());
+ return SentStatus.ERROR;
+ } catch (IOException ex) {
+ LOG.debug("Failed to send analytics report: " + ex.getMessage());
+ return SentStatus.ERROR;
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsQueue.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsQueue.java
new file mode 100644
index 0000000..7bcad09
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsQueue.java
@@ -0,0 +1,70 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import com.intellij.openapi.diagnostic.Logger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentLinkedQueue;
+
+/**
+ * Thread-safe queue for collecting analytics events. Events are accumulated and can be drained for
+ * batch reporting.
+ */
+public class AnalyticsQueue {
+
+ private static final Logger LOG = Logger.getInstance(AnalyticsQueue.class);
+
+ private static final int MAX_QUEUE_SIZE = 1000;
+
+ private final ConcurrentLinkedQueue events = new ConcurrentLinkedQueue<>();
+
+ /**
+ * Tracks an analytics event.
+ *
+ * @param eventType The type of event to track
+ */
+ public void track(String eventType) {
+ track(eventType, Map.of());
+ }
+
+ /**
+ * Tracks an analytics event with properties.
+ *
+ * @param eventType The type of event to track
+ * @param properties Additional event properties
+ */
+ public void track(String eventType, Map properties) {
+ if (events.size() >= MAX_QUEUE_SIZE) {
+ LOG.warn("Analytics queue is full, dropping oldest event");
+ events.poll();
+ }
+
+ AnalyticsEvent event = AnalyticsEvent.of(eventType, properties);
+ events.add(event);
+ LOG.debug("Tracked analytics event: " + eventType);
+ }
+
+ /**
+ * Drains all events from the queue.
+ *
+ * @return List of all queued events (queue is emptied)
+ */
+ public List drain() {
+ List drained = new ArrayList<>();
+ AnalyticsEvent event;
+ while ((event = events.poll()) != null) {
+ drained.add(event);
+ }
+ return drained;
+ }
+
+ /** Returns the number of events in the queue. */
+ public int size() {
+ return events.size();
+ }
+
+ /** Checks if the queue is empty. */
+ public boolean isEmpty() {
+ return events.isEmpty();
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportDto.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportDto.java
new file mode 100644
index 0000000..235911a
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportDto.java
@@ -0,0 +1,54 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import java.util.List;
+import java.util.Map;
+
+/** DTO for sending analytics report to the backend. */
+public record AnalyticsReportDto(
+ String installationId,
+ IdeContextDto ideContext,
+ List events,
+ long reportedAt) {
+
+ public static AnalyticsReportDto from(
+ String installationId, IdeContext context, List events) {
+ return new AnalyticsReportDto(
+ installationId,
+ IdeContextDto.from(context),
+ events.stream().map(AnalyticsEventDto::from).toList(),
+ System.currentTimeMillis());
+ }
+
+ public record IdeContextDto(
+ String ideName,
+ String ideVersion,
+ String ideBuild,
+ String ideProductCode,
+ String pluginVersion,
+ String osName,
+ String osVersion,
+ String osArch,
+ String javaVersion,
+ String locale) {
+ public static IdeContextDto from(IdeContext context) {
+ return new IdeContextDto(
+ context.ideName(),
+ context.ideVersion(),
+ context.ideBuild(),
+ context.ideProductCode(),
+ context.pluginVersion(),
+ context.osName(),
+ context.osVersion(),
+ context.osArch(),
+ context.javaVersion(),
+ context.locale());
+ }
+ }
+
+ public record AnalyticsEventDto(
+ String eventType, long timestamp, Map properties) {
+ public static AnalyticsEventDto from(AnalyticsEvent event) {
+ return new AnalyticsEventDto(event.eventType(), event.timestamp(), event.properties());
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportingTask.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportingTask.java
new file mode 100644
index 0000000..3c3bbcb
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsReportingTask.java
@@ -0,0 +1,131 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import static com.codeclocker.plugin.intellij.ScheduledExecutor.EXECUTOR;
+import static com.codeclocker.plugin.intellij.reporting.SentStatus.ERROR;
+import static java.util.concurrent.TimeUnit.MINUTES;
+
+import com.codeclocker.plugin.intellij.reporting.SentStatus;
+import com.intellij.openapi.Disposable;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledFuture;
+
+/**
+ * Scheduled task that flushes analytics events to the backend every 5 minutes. This is an
+ * application-level service that manages analytics collection and reporting.
+ */
+public class AnalyticsReportingTask implements Disposable {
+
+ private static final Logger LOG = Logger.getInstance(AnalyticsReportingTask.class);
+
+ private static final int FLUSH_INTERVAL_MINUTES = 5;
+
+ private final AnalyticsQueue analyticsQueue;
+ private final AnalyticsHttpClient analyticsHttpClient;
+
+ private ScheduledFuture> task;
+ private IdeContext cachedIdeContext;
+
+ public AnalyticsReportingTask() {
+ this.analyticsQueue = new AnalyticsQueue();
+ this.analyticsHttpClient =
+ ApplicationManager.getApplication().getService(AnalyticsHttpClient.class);
+ }
+
+ /** Starts the scheduled analytics reporting task. */
+ public void schedule() {
+ if (task != null && !task.isCancelled()) {
+ return;
+ }
+
+ // Track plugin started event
+ track(AnalyticsEventType.PLUGIN_STARTED);
+
+ task =
+ EXECUTOR.scheduleWithFixedDelay(
+ this::flushAnalytics, FLUSH_INTERVAL_MINUTES, FLUSH_INTERVAL_MINUTES, MINUTES);
+
+ LOG.info("Analytics reporting task scheduled (every " + FLUSH_INTERVAL_MINUTES + " minutes)");
+ }
+
+ /**
+ * Tracks an analytics event.
+ *
+ * @param eventType The type of event to track
+ */
+ public void track(String eventType) {
+ analyticsQueue.track(eventType);
+ }
+
+ /**
+ * Tracks an analytics event with properties.
+ *
+ * @param eventType The type of event to track
+ * @param properties Additional event properties
+ */
+ public void track(String eventType, Map properties) {
+ analyticsQueue.track(eventType, properties);
+ }
+
+ /** Flushes queued analytics events to the backend. */
+ public void flushAnalytics() {
+ try {
+ List events = analyticsQueue.drain();
+ if (events.isEmpty()) {
+ LOG.debug("No analytics events to flush");
+ return;
+ }
+
+ LOG.debug("Flushing " + events.size() + " analytics events");
+
+ String installationId = InstallationIdPersistence.getInstallationId();
+ IdeContext ideContext = getIdeContext();
+
+ AnalyticsReportDto report = AnalyticsReportDto.from(installationId, ideContext, events);
+ SentStatus status = analyticsHttpClient.sendAnalyticsReport(report);
+
+ if (status == ERROR) {
+ LOG.warn("Failed to send analytics report, re-queuing " + events.size() + " events");
+ // Re-queue events for retry
+ for (AnalyticsEvent event : events) {
+ analyticsQueue.track(event.eventType(), event.properties());
+ }
+ } else {
+ LOG.info("Successfully sent " + events.size() + " analytics events");
+ }
+ } catch (Exception ex) {
+ LOG.warn("Error flushing analytics: " + ex.getMessage(), ex);
+ }
+ }
+
+ private IdeContext getIdeContext() {
+ if (cachedIdeContext == null) {
+ cachedIdeContext = IdeContext.current();
+ }
+ return cachedIdeContext;
+ }
+
+ @Override
+ public void dispose() {
+ LOG.info("Disposing AnalyticsReportingTask - flushing remaining events");
+
+ // Track plugin stopped event
+ track(AnalyticsEventType.PLUGIN_STOPPED);
+
+ try {
+ // Perform final flush
+ flushAnalytics();
+ LOG.info("Final analytics flush completed");
+ } catch (Exception e) {
+ LOG.warn("Error during final analytics flush", e);
+ }
+
+ if (task != null) {
+ task.cancel(false);
+ }
+
+ LOG.info("AnalyticsReportingTask disposed");
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/IdeContext.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/IdeContext.java
new file mode 100644
index 0000000..454d450
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/IdeContext.java
@@ -0,0 +1,48 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import com.intellij.ide.plugins.IdeaPluginDescriptor;
+import com.intellij.ide.plugins.PluginManagerCore;
+import com.intellij.openapi.application.ApplicationInfo;
+import com.intellij.openapi.extensions.PluginId;
+import java.util.Locale;
+
+/** Contains information about the IDE environment. */
+public record IdeContext(
+ String ideName,
+ String ideVersion,
+ String ideBuild,
+ String ideProductCode,
+ String pluginVersion,
+ String osName,
+ String osVersion,
+ String osArch,
+ String javaVersion,
+ String locale) {
+
+ private static final String PLUGIN_ID = "com.codeclocker";
+
+ /** Creates an IdeContext with current IDE and system information. */
+ public static IdeContext current() {
+ ApplicationInfo appInfo = ApplicationInfo.getInstance();
+
+ return new IdeContext(
+ appInfo.getFullApplicationName(),
+ appInfo.getFullVersion(),
+ appInfo.getBuild().asString(),
+ appInfo.getBuild().getProductCode(),
+ getPluginVersion(),
+ System.getProperty("os.name"),
+ System.getProperty("os.version"),
+ System.getProperty("os.arch"),
+ System.getProperty("java.version"),
+ Locale.getDefault().toString());
+ }
+
+ private static String getPluginVersion() {
+ IdeaPluginDescriptor plugin = PluginManagerCore.getPlugin(PluginId.getId(PLUGIN_ID));
+ if (plugin != null) {
+ return plugin.getVersion();
+ }
+ return "unknown";
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/InstallationIdPersistence.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/InstallationIdPersistence.java
new file mode 100644
index 0000000..d84cd98
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/InstallationIdPersistence.java
@@ -0,0 +1,40 @@
+package com.codeclocker.plugin.intellij.analytics;
+
+import com.intellij.ide.util.PropertiesComponent;
+import com.intellij.openapi.diagnostic.Logger;
+import java.util.UUID;
+import org.jetbrains.annotations.NotNull;
+
+/**
+ * Manages a unique installation ID for anonymous analytics tracking. The ID is generated once and
+ * persisted across IDE sessions.
+ */
+public class InstallationIdPersistence {
+
+ private static final Logger LOG = Logger.getInstance(InstallationIdPersistence.class);
+
+ private static final String INSTALLATION_ID_PROPERTY = "com.codeclocker.installation-id";
+
+ /**
+ * Gets the installation ID, generating one if it doesn't exist.
+ *
+ * @return The unique installation ID for this IDE installation
+ */
+ @NotNull
+ public static String getInstallationId() {
+ PropertiesComponent properties = PropertiesComponent.getInstance();
+ String installationId = properties.getValue(INSTALLATION_ID_PROPERTY);
+
+ if (installationId == null || installationId.isBlank()) {
+ installationId = generateInstallationId();
+ properties.setValue(INSTALLATION_ID_PROPERTY, installationId);
+ LOG.info("Generated new installation ID: " + installationId);
+ }
+
+ return installationId;
+ }
+
+ private static String generateInstallationId() {
+ return UUID.randomUUID().toString();
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java
index 3495847..be399f7 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyPersistence.java
@@ -2,7 +2,10 @@
import static org.apache.commons.lang3.StringUtils.isBlank;
+import com.codeclocker.plugin.intellij.reporting.DataReportingTask;
+import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask;
import com.intellij.ide.util.PropertiesComponent;
+import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.diagnostic.Logger;
import org.jetbrains.annotations.Nullable;
@@ -22,9 +25,30 @@ public static String getApiKey() {
public static void persistApiKey(String apiKey) {
if (isBlank(apiKey)) {
- PropertiesComponent.getInstance().setValue(CODE_CLOCKER_API_KEY_PROPERTY, null);
+ unsetApiKey();
} else {
PropertiesComponent.getInstance().setValue(CODE_CLOCKER_API_KEY_PROPERTY, apiKey);
+ // Sync any locally stored data to the server now that we have an API key
+ syncLocalDataToServer(apiKey);
+ }
+ }
+
+ private static void syncLocalDataToServer(String apiKey) {
+ try {
+ DataReportingTask dataReportingTask =
+ ApplicationManager.getApplication().getService(DataReportingTask.class);
+ if (dataReportingTask != null) {
+ dataReportingTask.syncLocalDataToServer(apiKey);
+ }
+
+ // Refetch trends data now that local data has been synced
+ TimeComparisonFetchTask timeComparisonFetchTask =
+ ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class);
+ if (timeComparisonFetchTask != null) {
+ timeComparisonFetchTask.refetch();
+ }
+ } catch (Exception e) {
+ LOG.warn("Failed to sync local data after API key was set", e);
}
}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java
new file mode 100644
index 0000000..bfc5941
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalStateRepository.java
@@ -0,0 +1,80 @@
+package com.codeclocker.plugin.intellij.local;
+
+import com.codeclocker.plugin.intellij.reporting.DataReportingTask;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.components.PersistentStateComponent;
+import com.intellij.openapi.components.State;
+import com.intellij.openapi.components.Storage;
+import com.intellij.openapi.diagnostic.Logger;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Repository for persisting tracked time and VCS changes locally. Uses IntelliJ's
+ * PersistentStateComponent for automatic XML serialization. Data is retained for max 2 weeks with
+ * hour granularity.
+ */
+@State(name = "CodeClockerLocalState", storages = @Storage("codeclocker-local-state.xml"))
+public class LocalStateRepository implements PersistentStateComponent {
+
+ private static final Logger LOG = Logger.getInstance(LocalStateRepository.class);
+ private static final DateTimeFormatter DATETIME_HOUR_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd-HH");
+
+ private LocalTrackerState state = new LocalTrackerState();
+
+ @Override
+ public @Nullable LocalTrackerState getState() {
+ ApplicationManager.getApplication()
+ .getService(DataReportingTask.class)
+ .saveToLocalStorageIfApiKeyIsEmpty();
+ return state;
+ }
+
+ @Override
+ public void loadState(@NotNull LocalTrackerState state) {
+ this.state = state;
+ // Cleanup old entries on load
+ int removed = this.state.cleanupOldEntries();
+ if (removed > 0) {
+ LOG.info("Cleaned up " + removed + " old hour entries from local state");
+ }
+ LOG.debug("Loaded local tracker state with " + this.state.getTotalEntries() + " entries");
+ }
+
+ 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);
+ }
+
+ public Map> getAllData() {
+ return state.getHourlyActivity();
+ }
+
+ public Map> getAllUnreportedData() {
+ return state.getUnreportedData();
+ }
+
+ public void markAllDataAsReported() {
+ LOG.debug("Marking all local state as reported");
+ state.getHourlyActivity().values().stream()
+ .flatMap(projects -> projects.values().stream())
+ .forEach(snapshot -> snapshot.setReported(true));
+ }
+
+ public boolean hasUnreportedData() {
+ return state.hasUnreportedEntries();
+ }
+
+ /** Cleanup entries older than 2 weeks. Called periodically. */
+ public void rotate() {
+ int removed = state.cleanupOldEntries();
+ if (removed > 0) {
+ LOG.info("Cleaned up " + removed + " old hour entries");
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java
new file mode 100644
index 0000000..04d4da7
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/local/LocalTrackerState.java
@@ -0,0 +1,110 @@
+package com.codeclocker.plugin.intellij.local;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+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.
+ */
+public class LocalTrackerState {
+
+ private static final int RETENTION_DAYS = 14;
+ private static final DateTimeFormatter DATETIME_HOUR_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd-HH");
+
+ private Map> hourlyActivity = new HashMap<>();
+
+ public Map> getHourlyActivity() {
+ return hourlyActivity;
+ }
+
+ public void setHourlyActivity(Map> hourlyActivity) {
+ this.hourlyActivity = hourlyActivity;
+ }
+
+ public void mergeProject(
+ String datetimeHour, String projectName, ProjectActivitySnapshot newSnapshot) {
+ hourlyActivity.compute(
+ datetimeHour,
+ (dt, projects) -> {
+ if (projects == null) {
+ projects = new HashMap<>();
+ }
+ projects.merge(
+ projectName,
+ newSnapshot,
+ (existing, incoming) ->
+ new ProjectActivitySnapshot(
+ existing.getCodedTimeSeconds() + incoming.getCodedTimeSeconds(),
+ existing.getAdditions() + incoming.getAdditions(),
+ existing.getRemovals() + incoming.getRemovals(),
+ existing.isReported()));
+ return projects;
+ });
+ }
+
+ /** Removes entries older than 2 weeks. Returns number of hour slots removed. */
+ public int cleanupOldEntries() {
+ LocalDateTime cutoffDateTime = LocalDateTime.now().minusDays(RETENTION_DAYS);
+ int removedCount = 0;
+
+ Iterator iterator = hourlyActivity.keySet().iterator();
+ while (iterator.hasNext()) {
+ String datetimeStr = iterator.next();
+ try {
+ LocalDateTime entryDateTime = LocalDateTime.parse(datetimeStr, DATETIME_HOUR_FORMATTER);
+ if (entryDateTime.isBefore(cutoffDateTime)) {
+ iterator.remove();
+ removedCount++;
+ }
+ } catch (Exception e) {
+ // Invalid format, remove the entry
+ iterator.remove();
+ removedCount++;
+ }
+ }
+
+ return removedCount;
+ }
+
+ public boolean isEmpty() {
+ return hourlyActivity.isEmpty();
+ }
+
+ public int getTotalEntries() {
+ return hourlyActivity.values().stream().mapToInt(Map::size).sum();
+ }
+
+ public void clean() {
+ hourlyActivity.clear();
+ }
+
+ public boolean hasUnreportedEntries() {
+ return hourlyActivity.values().stream()
+ .flatMap(m -> m.values().stream())
+ .anyMatch(Predicate.not(ProjectActivitySnapshot::isReported));
+ }
+
+ public Map> getUnreportedData() {
+ Map> unreported = new HashMap<>();
+ for (Map.Entry> hourEntry :
+ hourlyActivity.entrySet()) {
+ Map unreportedProjects = new HashMap<>();
+ for (Map.Entry projectEntry :
+ hourEntry.getValue().entrySet()) {
+ if (!projectEntry.getValue().isReported()) {
+ unreportedProjects.put(projectEntry.getKey(), projectEntry.getValue());
+ }
+ }
+ if (!unreportedProjects.isEmpty()) {
+ unreported.put(hourEntry.getKey(), unreportedProjects);
+ }
+ }
+ return unreported;
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java b/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java
new file mode 100644
index 0000000..ab3deb9
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/local/ProjectActivitySnapshot.java
@@ -0,0 +1,57 @@
+package com.codeclocker.plugin.intellij.local;
+
+/**
+ * Snapshot of activity data for a single project. Stores coded time in seconds and VCS change
+ * counts.
+ */
+public class ProjectActivitySnapshot {
+
+ private long codedTimeSeconds;
+ private long additions;
+ private long removals;
+ private boolean reported;
+
+ public ProjectActivitySnapshot() {
+ // Required for XML serialization
+ }
+
+ public ProjectActivitySnapshot(
+ long codedTimeSeconds, long additions, long removals, boolean reported) {
+ this.codedTimeSeconds = codedTimeSeconds;
+ this.additions = additions;
+ this.removals = removals;
+ this.reported = reported;
+ }
+
+ public long getCodedTimeSeconds() {
+ return codedTimeSeconds;
+ }
+
+ public void setCodedTimeSeconds(long codedTimeSeconds) {
+ this.codedTimeSeconds = codedTimeSeconds;
+ }
+
+ public long getAdditions() {
+ return additions;
+ }
+
+ public void setAdditions(long additions) {
+ this.additions = additions;
+ }
+
+ public long getRemovals() {
+ return removals;
+ }
+
+ public void setRemovals(long removals) {
+ this.removals = removals;
+ }
+
+ public boolean isReported() {
+ return reported;
+ }
+
+ public void setReported(boolean reported) {
+ this.reported = reported;
+ }
+}
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 23d0cc4..77847af 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/DataReportingTask.java
@@ -2,7 +2,6 @@
import static com.codeclocker.plugin.intellij.JsonMapper.OBJECT_MAPPER;
import static com.codeclocker.plugin.intellij.ScheduledExecutor.EXECUTOR;
-import static com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle.isActivityDataStoppedBeingCollected;
import static com.codeclocker.plugin.intellij.reporting.SentStatus.ERROR;
import static com.codeclocker.plugin.intellij.reporting.SentStatus.OK;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -11,6 +10,8 @@
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.LocalStateRepository;
+import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot;
import com.codeclocker.plugin.intellij.services.ChangesSample;
import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger;
import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectSample;
@@ -20,24 +21,34 @@
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
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.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Queue;
-import java.util.concurrent.TimeUnit;
+import java.util.concurrent.ScheduledFuture;
public final class DataReportingTask implements Disposable {
private static final Logger LOG = Logger.getInstance(CheckSubscriptionStateHttpClient.class);
+ private static final DateTimeFormatter DATETIME_HOUR_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd-HH");
+
private final int flushToServerFrequencySeconds;
private final TimeSpentPerProjectLogger timeSpentPerProjectLogger;
private final ChangesActivityTracker changesActivityTracker;
private final ActivitySampleHttpClient activitySampleHttpClient;
+ private final LocalStateRepository localStateRepository;
private final Queue unpublishedTimeSpentSamples = new ArrayDeque<>();
private final Queue unpublishedChangesSamples = new ArrayDeque<>();
+ private ScheduledFuture> task;
+
public DataReportingTask() {
this.changesActivityTracker =
ApplicationManager.getApplication().getService(ChangesActivityTracker.class);
@@ -48,36 +59,227 @@ public DataReportingTask() {
ConfigProvider configProvider =
ApplicationManager.getApplication().getService(ConfigProvider.class);
this.flushToServerFrequencySeconds = configProvider.getActivityDataFlushFrequencySeconds();
+ this.localStateRepository =
+ ApplicationManager.getApplication().getService(LocalStateRepository.class);
}
public void schedule() {
- EXECUTOR.scheduleWithFixedDelay(
- this::sendActivitySampleToServer,
- flushToServerFrequencySeconds,
- flushToServerFrequencySeconds,
- SECONDS);
+ if (task != null && !task.isCancelled()) {
+ return;
+ }
+
+ task =
+ EXECUTOR.scheduleWithFixedDelay(
+ this::flushActivityData,
+ flushToServerFrequencySeconds,
+ flushToServerFrequencySeconds,
+ SECONDS);
}
- private void sendActivitySampleToServer() {
+ public void flushActivityData() {
try {
String apiKey = ApiKeyLifecycle.getActiveApiKey();
- if (shouldSkipSendingActivityData(apiKey)) {
+
+ // Cleanup old local data periodically
+ localStateRepository.rotate();
+
+ Map timeSamples = timeSpentPerProjectLogger.drain();
+ Map> changesSamples = changesActivityTracker.drain();
+ if (timeSamples.isEmpty() && changesSamples.isEmpty()) {
+ LOG.debug("No activity data to save locally");
return;
}
- // Validate timers before flushing to detect inconsistencies
- validateTimersBeforeFlush();
+ saveToLocalStorage(timeSamples, changesSamples);
+
+ // If API key is available, try to sync to server
+ if (!isBlank(apiKey)) {
+ sendActivitySampleToServer(apiKey, timeSamples, changesSamples);
+ }
+ } catch (Exception ex) {
+ LOG.debug("Error flushing activity data: {}", ex.getMessage());
+ }
+ }
- SentStatus unpublishedSamplesPublishStatus = publishUnpublishedSamples(apiKey);
- if (unpublishedSamplesPublishStatus == ERROR) {
- LOG.debug("Failed to publish unpublished samples");
+ public void saveToLocalStorageIfApiKeyIsEmpty() { // todo: store in any case
+ String apiKey = ApiKeyLifecycle.getActiveApiKey();
+ if (isBlank(apiKey)) {
+ Map timeSamples = timeSpentPerProjectLogger.drain();
+ Map> changesSamples = changesActivityTracker.drain();
+ if (timeSamples.isEmpty() && changesSamples.isEmpty()) {
+ LOG.debug("No activity data to save locally");
return;
}
- publishTimeSpentSample(apiKey);
- publishChangesSample(apiKey);
- } catch (Exception ex) {
- LOG.debug("Error sending activity sample: {}", ex.getMessage());
+ saveToLocalStorage(timeSamples, changesSamples);
+ }
+ }
+
+ public void saveToLocalStorage(
+ Map timeSamples,
+ Map> changesSamples) {
+
+ // Aggregate VCS changes per project
+ Map projectAdditions = new HashMap<>();
+ Map projectRemovals = new HashMap<>();
+
+ for (Entry> projectEntry : changesSamples.entrySet()) {
+ String projectName = projectEntry.getKey();
+ long totalAdditions = 0;
+ long totalRemovals = 0;
+
+ for (ChangesSample fileSample : projectEntry.getValue().values()) {
+ totalAdditions += fileSample.additions().get();
+ totalRemovals += fileSample.removals().get();
+ }
+
+ projectAdditions.put(projectName, totalAdditions);
+ projectRemovals.put(projectName, totalRemovals);
+ }
+
+ // Save time spent per project
+ for (Entry entry : timeSamples.entrySet()) {
+ String projectName = entry.getKey();
+ long timeSeconds = entry.getValue().timeSpent().getSeconds();
+ long additions = projectAdditions.getOrDefault(projectName, 0L);
+ long removals = projectRemovals.getOrDefault(projectName, 0L);
+
+ ProjectActivitySnapshot snapshot =
+ new ProjectActivitySnapshot(timeSeconds, additions, removals, false);
+ localStateRepository.mergeProjectCurrentHour(projectName, snapshot);
+ }
+
+ // Save VCS changes for projects without time entries
+ for (String projectName : projectAdditions.keySet()) {
+ if (!timeSamples.containsKey(projectName)) {
+ long additions = projectAdditions.get(projectName);
+ long removals = projectRemovals.get(projectName);
+
+ ProjectActivitySnapshot snapshot =
+ new ProjectActivitySnapshot(0, additions, removals, false);
+ localStateRepository.mergeProjectCurrentHour(projectName, snapshot);
+ }
+ }
+
+ LOG.debug("Saved activity data to local storage for " + timeSamples.size() + " projects");
+ }
+
+ private void sendActivitySampleToServer(
+ String apiKey,
+ Map timeSamples,
+ Map> changesSamples) {
+ // Validate timers before flushing to detect inconsistencies
+ validateTimersBeforeFlush();
+
+ // First, sync any locally stored data to the server
+ syncLocalDataToServer(apiKey);
+
+ SentStatus unpublishedSamplesPublishStatus = publishUnpublishedSamples(apiKey);
+ if (unpublishedSamplesPublishStatus == ERROR) {
+ LOG.debug("Failed to publish unpublished samples");
+ return;
+ }
+
+ publishTimeSpentSample(apiKey, timeSamples);
+ publishChangesSample(apiKey, changesSamples);
+ }
+
+ public void syncLocalDataToServer(String apiKey) {
+ if (!localStateRepository.hasUnreportedData()) {
+ return;
+ }
+
+ LOG.info("Found locally stored data, syncing to server...");
+
+ // Get data without clearing - only clear after successful send
+ Map> localData =
+ localStateRepository.getAllUnreportedData();
+ if (localData.isEmpty()) {
+ 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
+ Map> changesByProject = new HashMap<>();
+
+ for (Entry> hourEntry : localData.entrySet()) {
+ String datetimeHourStr = hourEntry.getKey();
+ long samplingStartedAt = datetimeHourToTimestamp(datetimeHourStr);
+
+ for (Entry projectEntry : hourEntry.getValue().entrySet()) {
+ String projectName = projectEntry.getKey();
+ ProjectActivitySnapshot snapshot = projectEntry.getValue();
+
+ // Aggregate time spent per project
+ if (snapshot.getCodedTimeSeconds() > 0) {
+ timeSpentByProject.merge(
+ projectName,
+ new TimeSpentSampleDto(samplingStartedAt, snapshot.getCodedTimeSeconds()),
+ (existing, incoming) ->
+ new TimeSpentSampleDto(
+ Math.min(existing.samplingStartedAt(), incoming.samplingStartedAt()),
+ existing.timeSpentSeconds() + incoming.timeSpentSeconds()));
+ }
+
+ // Aggregate VCS changes per project
+ if (snapshot.getAdditions() > 0 || snapshot.getRemovals() > 0) {
+ String syntheticFileName = "local-sync-" + datetimeHourStr;
+ ChangesSampleDto changesDto =
+ new ChangesSampleDto(
+ samplingStartedAt,
+ snapshot.getAdditions(),
+ snapshot.getRemovals(),
+ Collections.emptyMap());
+
+ changesByProject
+ .computeIfAbsent(projectName, k -> new HashMap<>())
+ .put(syntheticFileName, changesDto);
+ }
+ }
+ }
+
+ // Send time spent data
+ boolean timeSyncSuccess = true;
+ if (!timeSpentByProject.isEmpty()) {
+ String timeJson = toJson(timeSpentByProject);
+ SentStatus status = activitySampleHttpClient.sendTimeSpentSample(apiKey, timeJson);
+ if (status == ERROR) {
+ LOG.warn("Failed to sync local time spent data to server, will retry later");
+ timeSyncSuccess = false;
+ } else {
+ LOG.info("Synced local time spent data for " + timeSpentByProject.size() + " projects");
+ }
+ }
+
+ // Send changes data
+ boolean changesSyncSuccess = true;
+ if (!changesByProject.isEmpty()) {
+ String changesJson = toJson(changesByProject);
+ SentStatus status = activitySampleHttpClient.sendChangesSample(apiKey, changesJson);
+ if (status == ERROR) {
+ LOG.warn("Failed to sync local VCS changes data to server, will retry later");
+ changesSyncSuccess = false;
+ } else {
+ LOG.info("Synced local VCS changes data for " + changesByProject.size() + " projects");
+ }
+ }
+
+ // Only clear local data if both syncs succeeded
+ if (timeSyncSuccess && changesSyncSuccess) {
+ localStateRepository.markAllDataAsReported();
+ LOG.info("Cleared local data after successful sync");
+ }
+ }
+
+ private static long datetimeHourToTimestamp(String datetimeHourStr) {
+ try {
+ LocalDateTime dateTime = LocalDateTime.parse(datetimeHourStr, DATETIME_HOUR_FORMATTER);
+ return dateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
+ } catch (Exception e) {
+ // Fallback to current time if parsing fails
+ return System.currentTimeMillis();
}
}
@@ -100,13 +302,8 @@ private void validateTimersBeforeFlush() {
}
}
- private void publishTimeSpentSample(String apiKey) {
- Map sample = timeSpentPerProjectLogger.drain();
- if (sample.isEmpty()) {
- LOG.debug("Activity sample is empty. Doing nothing");
- return;
- }
-
+ private void publishTimeSpentSample(
+ String apiKey, Map sample) {
Map dto = toTimeSpentDto(sample);
String json = toJson(dto);
@@ -117,13 +314,7 @@ private void publishTimeSpentSample(String apiKey) {
}
}
- private void publishChangesSample(String apiKey) {
- Map> sample = changesActivityTracker.drain();
- if (sample.isEmpty()) {
- LOG.debug("Changes sample is empty. Doing nothing");
- return;
- }
-
+ private void publishChangesSample(String apiKey, Map> sample) {
Map> dto = toChangesDto(sample);
String json = toJson(dto);
@@ -160,10 +351,6 @@ private SentStatus publishUnpublishedSamples(String apiKey) {
return OK;
}
- private static boolean shouldSkipSendingActivityData(String apiKey) {
- return isBlank(apiKey) || isActivityDataStoppedBeingCollected();
- }
-
private static Map> toChangesDto(
Map> activity) {
Map> sampleByProjectDto = new HashMap<>();
@@ -218,30 +405,15 @@ public void dispose() {
try {
// Perform final flush to prevent data loss
- sendActivitySampleToServer();
+ flushActivityData();
LOG.info("Final flush completed successfully");
} catch (Exception e) {
LOG.warn("Error during final flush before shutdown", e);
}
- // Shutdown the executor gracefully
- EXECUTOR.shutdown();
-
- try {
- // Wait up to 5 seconds for any remaining tasks to complete
- if (!EXECUTOR.awaitTermination(5, TimeUnit.SECONDS)) {
- LOG.warn("Executor did not terminate within 5 seconds, forcing shutdown");
- EXECUTOR.shutdownNow();
-
- // Wait a bit more for tasks to respond to being cancelled
- if (!EXECUTOR.awaitTermination(2, TimeUnit.SECONDS)) {
- LOG.error("Executor did not terminate even after shutdownNow");
- }
- }
- } catch (InterruptedException e) {
- LOG.warn("Interrupted while waiting for executor termination", e);
- EXECUTOR.shutdownNow();
- Thread.currentThread().interrupt();
+ // Cancel the scheduled task
+ if (task != null) {
+ task.cancel(false);
}
LOG.info("DataReportingTask disposed successfully");
diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java
new file mode 100644
index 0000000..9372154
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonFetchTask.java
@@ -0,0 +1,136 @@
+package com.codeclocker.plugin.intellij.reporting;
+
+import static com.codeclocker.plugin.intellij.ScheduledExecutor.EXECUTOR;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.apache.commons.lang3.StringUtils.isNotBlank;
+
+import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle;
+import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimeComparisonResponse;
+import com.codeclocker.plugin.intellij.reporting.TimeComparisonHttpClient.TimePeriodComparisonDto;
+import com.intellij.openapi.Disposable;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import java.time.DayOfWeek;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.temporal.TemporalAdjusters;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.atomic.AtomicReference;
+
+public final class TimeComparisonFetchTask implements Disposable {
+
+ private static final Logger LOG = Logger.getInstance(TimeComparisonFetchTask.class);
+ private static final int FETCH_INTERVAL_SECONDS = 60;
+
+ private final TimeComparisonHttpClient httpClient;
+
+ private final AtomicReference todayVsYesterday = new AtomicReference<>();
+ private final AtomicReference thisWeekVsLastWeek =
+ new AtomicReference<>();
+ private ScheduledFuture> task;
+
+ public TimeComparisonFetchTask() {
+ this.httpClient = new TimeComparisonHttpClient();
+ }
+
+ public void schedule() {
+ if (task != null && !task.isCancelled()) {
+ return;
+ }
+
+ task = EXECUTOR.scheduleWithFixedDelay(this::fetchData, 0, FETCH_INTERVAL_SECONDS, SECONDS);
+ }
+
+ private void fetchData() {
+ try {
+ String apiKey = ApiKeyLifecycle.getActiveApiKey();
+ if (!isNotBlank(apiKey)) {
+ LOG.debug("No API key available, skipping time comparison fetch");
+ return;
+ }
+
+ ZoneId timeZone = ZoneId.systemDefault();
+ fetchTodayVsYesterday(apiKey, timeZone);
+ fetchThisWeekVsLastWeek(apiKey, timeZone);
+ } catch (Exception ex) {
+ LOG.debug("Error fetching time comparison data: {}", ex.getMessage());
+ }
+ }
+
+ /**
+ * Triggers an immediate refetch of time comparison data. Called after local data is synced to the
+ * server to update trends.
+ */
+ public void refetch() {
+ LOG.debug("Triggering immediate refetch of time comparison data");
+ fetchData();
+ }
+
+ private void fetchTodayVsYesterday(String apiKey, ZoneId timeZone) {
+ LocalDate today = LocalDate.now(timeZone);
+ LocalDate yesterday = today.minusDays(1);
+
+ Instant todayStart = toStartOfDay(today, timeZone);
+ Instant todayEnd = Instant.now();
+ Instant yesterdayStart = toStartOfDay(yesterday, timeZone);
+ Instant yesterdayEnd = toEndOfDay(yesterday, timeZone);
+
+ TimeComparisonResponse response =
+ httpClient.fetchTimeComparison(apiKey, todayStart, todayEnd, yesterdayStart, yesterdayEnd);
+
+ if (response.isSuccess()) {
+ todayVsYesterday.set(response.getComparison());
+ LOG.debug("Updated today vs yesterday comparison: {}", todayVsYesterday.get());
+ }
+ }
+
+ private void fetchThisWeekVsLastWeek(String apiKey, ZoneId timeZone) {
+ LocalDate today = LocalDate.now(timeZone);
+ LocalDate startOfThisWeek = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
+ LocalDate startOfLastWeek = startOfThisWeek.minusWeeks(1);
+ LocalDate endOfLastWeek = startOfThisWeek.minusDays(1);
+
+ Instant thisWeekStart = toStartOfDay(startOfThisWeek, timeZone);
+ Instant thisWeekEnd = Instant.now();
+ Instant lastWeekStart = toStartOfDay(startOfLastWeek, timeZone);
+ Instant lastWeekEnd = toEndOfDay(endOfLastWeek, timeZone);
+
+ TimeComparisonResponse response =
+ httpClient.fetchTimeComparison(
+ apiKey, thisWeekStart, thisWeekEnd, lastWeekStart, lastWeekEnd);
+
+ if (response.isSuccess()) {
+ thisWeekVsLastWeek.set(response.getComparison());
+ LOG.debug("Updated this week vs last week comparison: {}", thisWeekVsLastWeek.get());
+ }
+ }
+
+ private static Instant toStartOfDay(LocalDate date, ZoneId timeZone) {
+ return ZonedDateTime.of(date.atStartOfDay(), timeZone).toInstant();
+ }
+
+ private static Instant toEndOfDay(LocalDate date, ZoneId timeZone) {
+ return ZonedDateTime.of(date.atTime(23, 59, 59), timeZone).toInstant();
+ }
+
+ public TimePeriodComparisonDto getTodayVsYesterday() {
+ return todayVsYesterday.get();
+ }
+
+ public TimePeriodComparisonDto getThisWeekVsLastWeek() {
+ return thisWeekVsLastWeek.get();
+ }
+
+ public static TimeComparisonFetchTask getInstance() {
+ return ApplicationManager.getApplication().getService(TimeComparisonFetchTask.class);
+ }
+
+ @Override
+ public void dispose() {
+ if (task != null) {
+ task.cancel(false);
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java
new file mode 100644
index 0000000..e32cbe6
--- /dev/null
+++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/TimeComparisonHttpClient.java
@@ -0,0 +1,124 @@
+package com.codeclocker.plugin.intellij.reporting;
+
+import static com.codeclocker.plugin.intellij.HubHost.HUB_API_HOST;
+import static com.codeclocker.plugin.intellij.JsonMapper.OBJECT_MAPPER;
+import static com.codeclocker.plugin.intellij.Timeouts.CONNECT_TIMEOUT;
+import static com.codeclocker.plugin.intellij.Timeouts.READ_TIMEOUT;
+
+import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle;
+import com.intellij.openapi.application.ApplicationManager;
+import com.intellij.openapi.diagnostic.Logger;
+import com.intellij.util.io.HttpRequests;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+
+public class TimeComparisonHttpClient {
+
+ private static final Logger LOG = Logger.getInstance(TimeComparisonHttpClient.class);
+
+ private final ApiKeyLifecycle apiKeyLifecycle;
+
+ public TimeComparisonHttpClient() {
+ this.apiKeyLifecycle = ApplicationManager.getApplication().getService(ApiKeyLifecycle.class);
+ }
+
+ public TimeComparisonResponse fetchTimeComparison(
+ String apiKey,
+ Instant currentFrom,
+ Instant currentTo,
+ Instant previousFrom,
+ Instant previousTo) {
+ try {
+ String url = buildUrl(currentFrom, currentTo, previousFrom, previousTo);
+ LOG.debug("Fetching time comparison from: {}", url);
+
+ String response =
+ HttpRequests.request(url)
+ .connectTimeout(CONNECT_TIMEOUT)
+ .readTimeout(READ_TIMEOUT)
+ .tuner(connection -> connection.setRequestProperty("X-codeclocker-api-key", apiKey))
+ .readString();
+
+ LOG.debug("Received response: {}", response);
+
+ if (response.contains("Activity data stopped being collected")) {
+ apiKeyLifecycle.processHubErrorResponse(response);
+ return TimeComparisonResponse.subscriptionExpired();
+ }
+
+ TimePeriodComparisonDto dto =
+ OBJECT_MAPPER.readValue(response, TimePeriodComparisonDto.class);
+ return TimeComparisonResponse.success(dto);
+ } catch (IOException ex) {
+ LOG.warn("Error fetching time comparison: " + ex.getMessage(), ex);
+ return TimeComparisonResponse.error();
+ }
+ }
+
+ private static String buildUrl(
+ Instant currentFrom, Instant currentTo, Instant previousFrom, Instant previousTo) {
+ return HUB_API_HOST
+ + "/api/v1/plugin/time-comparison?"
+ + "currentFrom="
+ + encode(currentFrom.toString())
+ + "¤tTo="
+ + encode(currentTo.toString())
+ + "&previousFrom="
+ + encode(previousFrom.toString())
+ + "&previousTo="
+ + encode(previousTo.toString());
+ }
+
+ private static String encode(String value) {
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ public record TimePeriodComparisonDto(
+ long currentPeriodSeconds,
+ long previousPeriodSeconds,
+ long differenceSeconds,
+ int percentageChange) {}
+
+ public static class TimeComparisonResponse {
+ private final TimePeriodComparisonDto comparison;
+ private final boolean subscriptionExpired;
+ private final boolean error;
+
+ private TimeComparisonResponse(
+ TimePeriodComparisonDto comparison, boolean subscriptionExpired, boolean error) {
+ this.comparison = comparison;
+ this.subscriptionExpired = subscriptionExpired;
+ this.error = error;
+ }
+
+ public static TimeComparisonResponse success(TimePeriodComparisonDto comparison) {
+ return new TimeComparisonResponse(comparison, false, false);
+ }
+
+ public static TimeComparisonResponse subscriptionExpired() {
+ return new TimeComparisonResponse(null, true, false);
+ }
+
+ public static TimeComparisonResponse error() {
+ return new TimeComparisonResponse(null, false, true);
+ }
+
+ public TimePeriodComparisonDto getComparison() {
+ return comparison;
+ }
+
+ public boolean isSubscriptionExpired() {
+ return subscriptionExpired;
+ }
+
+ public boolean isError() {
+ return error;
+ }
+
+ public boolean isSuccess() {
+ return !subscriptionExpired && !error && comparison != null;
+ }
+ }
+}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java
index dc13536..06264da 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectLogger.java
@@ -7,6 +7,7 @@
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;
@@ -17,6 +18,7 @@ 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();
private final Map timingByProject = new ConcurrentHashMap<>();
private final AtomicReference currentProject = new AtomicReference<>();
@@ -36,12 +38,15 @@ public void log(Project project) {
timingByProject.compute(
project.getName(),
(name, sample) -> {
+ if (project.isDisposed()) {
+ return sample;
+ }
TimeTrackerWidgetService service = project.getService(TimeTrackerWidgetService.class);
service.resume();
if (sample == null) {
- return TimeSpentPerProjectSample.create();
+ return TimeSpentPerProjectSample.createStarted();
}
- return sample.resumeSpendingTime();
+ return sample.resume();
});
} finally {
lock.unlock();
@@ -55,6 +60,12 @@ private void pauseWatchForPrevProject(Project prevProject) {
timingByProject.compute(
prevProject.getName(),
(name, sample) -> {
+ if (prevProject.isDisposed()) {
+ if (sample != null) {
+ sample.pause();
+ }
+ return sample;
+ }
TimeTrackerWidgetService service =
prevProject.getService(TimeTrackerWidgetService.class);
service.pause();
@@ -84,6 +95,12 @@ public void pauseDueToInactivity() {
timingByProject.compute(
currentProject.getName(),
(name, sample) -> {
+ if (currentProject.isDisposed()) {
+ if (sample != null) {
+ sample.pause();
+ }
+ return sample;
+ }
TimeTrackerWidgetService service =
currentProject.getService(TimeTrackerWidgetService.class);
service.pause();
diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java
index 511f432..7925b76 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeSpentPerProjectSample.java
@@ -4,11 +4,11 @@
public record TimeSpentPerProjectSample(long samplingStartedAt, SafeStopWatch timeSpent) {
- public static TimeSpentPerProjectSample create() {
+ public static TimeSpentPerProjectSample createStarted() {
return new TimeSpentPerProjectSample(System.currentTimeMillis(), SafeStopWatch.createStarted());
}
- public TimeSpentPerProjectSample resumeSpendingTime() {
+ public TimeSpentPerProjectSample resume() {
timeSpent.resume();
return this;
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 0c6f01c..0206079 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/services/TimeTrackerWidgetService.java
@@ -1,5 +1,6 @@
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;
@@ -27,7 +28,6 @@ public class TimeTrackerWidgetService implements Disposable {
private final Project project;
private final TimeTrackerWidget widget;
private final AtomicLong initProjectTime = new AtomicLong(0);
- private final AtomicLong initTotalTime = new AtomicLong(0);
private final SafeStopWatch projectStopWatch = SafeStopWatch.createStopped();
private LocalDate lastDate = LocalDate.now();
@@ -39,9 +39,9 @@ public TimeTrackerWidgetService(Project project) {
startTicker();
}
- public void initialize(long initialSeconds, long totalSeconds) {
+ public void initialize(long initialSeconds) {
this.initProjectTime.set(initialSeconds);
- this.initTotalTime.set(totalSeconds);
+ this.projectStopWatch.reset();
repaintWidget();
}
@@ -54,10 +54,10 @@ public void resume() {
}
public long getTotalSeconds() {
- return initTotalTime.get() + GLOBAL_STOP_WATCH.getSeconds();
+ return GLOBAL_INIT_SECONDS.get() + GLOBAL_STOP_WATCH.getSeconds();
}
- private long getProjectSeconds() {
+ public long getProjectSeconds() {
return initProjectTime.get() + projectStopWatch.getSeconds();
}
@@ -108,7 +108,7 @@ private void checkMidnightReset() {
LOG.info("Midnight detected for project: " + project.getName());
initProjectTime.set(0);
- initTotalTime.set(0);
+ GLOBAL_INIT_SECONDS.set(0);
projectStopWatch.reset();
GLOBAL_ADDITIONS.set(0);
GLOBAL_REMOVALS.set(0);
diff --git a/src/main/java/com/codeclocker/plugin/intellij/subscription/SubscriptionStateCheckerTask.java b/src/main/java/com/codeclocker/plugin/intellij/subscription/SubscriptionStateCheckerTask.java
index f0688b0..8ab4f94 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/subscription/SubscriptionStateCheckerTask.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/subscription/SubscriptionStateCheckerTask.java
@@ -66,6 +66,5 @@ private void cancelTask() {
@Override
public void dispose() {
cancelTask();
- EXECUTOR.shutdown();
}
}
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 6d55d75..5cbdf8f 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerInitializer.java
@@ -1,5 +1,6 @@
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;
@@ -8,6 +9,8 @@
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.ProjectActivitySnapshot;
import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient;
import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient.DailyTimeResponse;
import com.codeclocker.plugin.intellij.reporting.DailyTimeHttpClient.ProjectStats;
@@ -18,7 +21,10 @@
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;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@@ -57,9 +63,9 @@ private static void fetchTimeAndInitialize() {
try {
String apiKey = ApiKeyLifecycle.getActiveApiKey();
if (isBlank(apiKey)) {
- LOG.debug("No active API key");
+ LOG.debug("No active API key, initializing from local state");
cancelRetryTask();
- initializeAllProjectWidgets(Map.of(), false);
+ initializeFromLocalState();
return;
}
@@ -112,7 +118,7 @@ private static void initializeAllProjectWidgets(
return;
}
- long totalTime =
+ long totalGlobalSeconds =
projectStats.values().stream()
.mapToLong(DailyTimeHttpClient.ProjectStats::timeSpentSeconds)
.sum();
@@ -123,12 +129,13 @@ private static void initializeAllProjectWidgets(
LOG.debug(
"Total time across all projects: {}s, additions: {}, removals: {}",
- totalTime,
+ totalGlobalSeconds,
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
GLOBAL_ADDITIONS.set(totalAdditions);
@@ -137,27 +144,29 @@ private static void initializeAllProjectWidgets(
"Initialized GLOBAL_ADDITIONS: {}, GLOBAL_REMOVALS: {}", totalAdditions, totalRemovals);
initializeVcsChanges(projectStats);
- initializeCodingTime(projectStats, totalTime);
+ initializeCodingTime(projectStats);
initialized = true;
}
- private static void initializeCodingTime(Map projectStats, long totalTime) {
+ 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;
- TimeTrackerWidgetService service = project.getService(TimeTrackerWidgetService.class);
- if (service != null) {
- service.initialize(initialSeconds, totalTime);
- LOG.debug(
- "Initialized timer widget for project {} with {}s (total: {}s)",
- projectName,
- initialSeconds,
- totalTime);
- }
+ 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);
}
}
@@ -205,4 +214,101 @@ private static synchronized void cancelRetryTask() {
retryTask.set(null);
}
}
+
+ private static void initializeFromLocalState() {
+ LocalStateRepository localState =
+ ApplicationManager.getApplication().getService(LocalStateRepository.class);
+ Map todayStats = aggregateTodayStats(localState);
+
+ if (todayStats.isEmpty() && initialized) {
+ LOG.debug("No local state data for today and already initialized, skipping");
+ 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();
+
+ LOG.info(
+ "Initializing from local state - total time: "
+ + totalTime
+ + "s, additions: "
+ + totalAdditions
+ + ", removals: "
+ + totalRemovals);
+
+ GLOBAL_STOP_WATCH.reset();
+ GLOBAL_INIT_SECONDS.set(totalTime);
+ GLOBAL_ADDITIONS.set(totalAdditions);
+ GLOBAL_REMOVALS.set(totalRemovals);
+
+ initializeVcsChangesFromLocalState(todayStats);
+ initializeCodingTimeFromLocalState(todayStats);
+
+ initialized = true;
+ }
+
+ private static Map aggregateTodayStats(
+ LocalStateRepository localState) {
+ String todayPrefix = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+ Map aggregated = new HashMap<>();
+
+ for (Map.Entry> hourEntry :
+ localState.getAllData().entrySet()) {
+ if (!hourEntry.getKey().startsWith(todayPrefix)) {
+ continue;
+ }
+
+ for (Map.Entry projectEntry :
+ hourEntry.getValue().entrySet()) {
+ String projectName = projectEntry.getKey();
+ ProjectActivitySnapshot snapshot = projectEntry.getValue();
+
+ aggregated.merge(
+ projectName,
+ snapshot,
+ (existing, incoming) ->
+ new ProjectActivitySnapshot(
+ existing.getCodedTimeSeconds() + incoming.getCodedTimeSeconds(),
+ existing.getAdditions() + incoming.getAdditions(),
+ existing.getRemovals() + incoming.getRemovals(),
+ existing.isReported()));
+ }
+ }
+
+ 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 =
+ ApplicationManager.getApplication().getService(ChangesActivityTracker.class);
+ changesTracker.clearAllProjectChanges();
+
+ for (Map.Entry entry : projectStats.entrySet()) {
+ String projectName = entry.getKey();
+ ProjectActivitySnapshot stats = entry.getValue();
+ changesTracker.initializeProjectChanges(
+ projectName, stats.getAdditions(), stats.getRemovals());
+ }
+ }
}
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 0b03f42..1f1b27a 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java
@@ -5,8 +5,13 @@
import static com.codeclocker.plugin.intellij.services.vcs.ChangesActivityTracker.GLOBAL_REMOVALS;
import static org.apache.commons.lang3.StringUtils.isNotBlank;
+import com.codeclocker.plugin.intellij.analytics.Analytics;
+import com.codeclocker.plugin.intellij.analytics.AnalyticsEventType;
+import com.codeclocker.plugin.intellij.apikey.ApiKeyLifecycle;
import com.codeclocker.plugin.intellij.apikey.ApiKeyPersistence;
import com.codeclocker.plugin.intellij.apikey.EnterApiKeyAction;
+import com.codeclocker.plugin.intellij.reporting.TimeComparisonFetchTask;
+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.intellij.ide.BrowserUtil;
@@ -19,43 +24,62 @@
import com.intellij.openapi.ui.popup.util.BaseListPopupStep;
import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import org.jetbrains.annotations.Nullable;
public class TimeTrackerPopup {
- private static final String OPEN_DETAILED_VIEW = "Web Dashboard →";
- private static final String ADD_API_KEY = "Add API Key";
+ private static final String WEB_DASHBOARD = "Web Dashboard →";
+ private static final String SAVE_HISTORY = "Save my history & unlock trends →";
+ private static final String RENEW_SUBSCRIPTION = "Renew subscription for trends →";
public static ListPopup create(Project project, String totalTime, String projectTime) {
ChangesActivityTracker tracker =
ApplicationManager.getApplication().getService(ChangesActivityTracker.class);
ProjectChangesCounters projectChanges = tracker.getProjectChanges(project.getName());
+ TimeComparisonFetchTask comparisonTask = TimeComparisonFetchTask.getInstance();
+
List items = new ArrayList<>();
items.add("Total: " + totalTime);
items.add(project.getName() + ": " + projectTime);
items.add("Total: " + getFormattedVcsChanges());
items.add(project.getName() + ": " + formatProjectVcsChanges(projectChanges));
- items.add(OPEN_DETAILED_VIEW);
+ items.add(formatTodayVsYesterday(comparisonTask.getTodayVsYesterday()));
+ items.add(formatThisWeekVsLastWeek(comparisonTask.getThisWeekVsLastWeek()));
boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey());
- if (!hasApiKey) {
- items.add(ADD_API_KEY);
+ if (ApiKeyLifecycle.isActivityDataStoppedBeingCollected()) {
+ items.add(RENEW_SUBSCRIPTION);
+ } else if (hasApiKey) {
+ items.add(WEB_DASHBOARD);
+ } else {
+ items.add(SAVE_HISTORY);
}
BaseListPopupStep step =
- new BaseListPopupStep<>("Activity Today", items) {
+ new BaseListPopupStep<>("Activity", items) {
@Override
public boolean isSelectable(String value) {
- return OPEN_DETAILED_VIEW.equals(value) || ADD_API_KEY.equals(value);
+ return WEB_DASHBOARD.equals(value)
+ || SAVE_HISTORY.equals(value)
+ || RENEW_SUBSCRIPTION.equals(value);
}
@Override
public PopupStep> onChosen(String selectedValue, boolean finalChoice) {
- if (OPEN_DETAILED_VIEW.equals(selectedValue)) {
+ if (WEB_DASHBOARD.equals(selectedValue)) {
+ Analytics.track(
+ AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "web_dashboard"));
BrowserUtil.browse(HUB_UI_HOST);
- } else if (ADD_API_KEY.equals(selectedValue)) {
+ } else if (SAVE_HISTORY.equals(selectedValue)) {
+ Analytics.track(
+ AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "save_history"));
EnterApiKeyAction.showAction();
+ } else if (RENEW_SUBSCRIPTION.equals(selectedValue)) {
+ Analytics.track(
+ AnalyticsEventType.WIDGET_POPUP_ACTION, Map.of("action", "renew_subscription"));
+ BrowserUtil.browse(HUB_UI_HOST + "/payment");
}
return FINAL_CHOICE;
}
@@ -67,16 +91,22 @@ public boolean hasSubstep(String selectedValue) {
@Override
public @Nullable ListSeparator getSeparatorAbove(String value) {
- if (OPEN_DETAILED_VIEW.equals(value)) {
+ if (WEB_DASHBOARD.equals(value)
+ || SAVE_HISTORY.equals(value)
+ || RENEW_SUBSCRIPTION.equals(value)) {
return new ListSeparator();
}
if (value.contains("Total: ") && value.contains("/")) {
- return new ListSeparator("Committed Lines");
+ return new ListSeparator("Committed Lines Today");
}
if (value.contains("Total: ") && !value.contains("/")) {
- return new ListSeparator("Coding Time");
+ return new ListSeparator("Coding Time Today");
+ }
+
+ if (value.contains("Today vs. Yesterday:")) {
+ return new ListSeparator("Coding Time Trends");
}
return null;
@@ -93,4 +123,43 @@ public static String getFormattedVcsChanges() {
private static String formatProjectVcsChanges(ProjectChangesCounters changes) {
return String.format("+%d / -%d", changes.additions().get(), changes.removals().get());
}
+
+ private static String formatTodayVsYesterday(TimePeriodComparisonDto comparison) {
+ if (comparison == null) {
+ return "Today vs. Yesterday: --";
+ }
+ return String.format(
+ "Today vs. Yesterday: %s / %s",
+ formatTimeDifference(comparison.differenceSeconds()),
+ formatPercentage(comparison.percentageChange()));
+ }
+
+ private static String formatThisWeekVsLastWeek(TimePeriodComparisonDto comparison) {
+ if (comparison == null) {
+ return "This week vs. Last week: --";
+ }
+ return String.format(
+ "This week vs. Last week: %s / %s",
+ formatTimeDifference(comparison.differenceSeconds()),
+ formatPercentage(comparison.percentageChange()));
+ }
+
+ private static String formatTimeDifference(long diffSeconds) {
+ String sign = diffSeconds >= 0 ? "+" : "-";
+ long absDiff = Math.abs(diffSeconds);
+ long hours = absDiff / 3600;
+ long minutes = (absDiff % 3600) / 60;
+
+ if (hours > 0) {
+ return String.format("%s%dh %dm", sign, hours, minutes);
+ }
+ return String.format("%s%dm", sign, minutes);
+ }
+
+ private static String formatPercentage(int percentage) {
+ if (percentage >= 0) {
+ return String.format("↗%d%%", percentage);
+ }
+ return String.format("↘%d%%", Math.abs(percentage));
+ }
}
diff --git a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java
index e7655dd..0549b86 100644
--- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java
+++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java
@@ -1,5 +1,7 @@
package com.codeclocker.plugin.intellij.widget;
+import com.codeclocker.plugin.intellij.analytics.Analytics;
+import com.codeclocker.plugin.intellij.analytics.AnalyticsEventType;
import com.codeclocker.plugin.intellij.services.TimeTrackerWidgetService;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.project.Project;
@@ -81,17 +83,14 @@ public String getTooltipText() {
String totalTime = service.getFormattedTotalTime();
String projectTime = service.getFormattedProjectTime();
- return "Total coding time today: "
- + totalTime
- + ". Time on "
- + project.getName()
- + ": "
- + projectTime;
+ return "Total today: " + totalTime + ". Time on " + project.getName() + ": " + projectTime;
}
@Nullable
@Override
public ListPopup getPopup() {
+ Analytics.track(AnalyticsEventType.STATUS_BAR_WIDGET_CLICK);
+
String totalTime = service.getFormattedTotalTime();
String projectTime = service.getFormattedProjectTime();
diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml
index af3d482..21b0e71 100644
--- a/src/main/resources/META-INF/plugin.xml
+++ b/src/main/resources/META-INF/plugin.xml
@@ -25,9 +25,17 @@
serviceImplementation="com.codeclocker.plugin.intellij.subscription.SubscriptionStateCheckerTask"/>
+
+
+
+
+
+
+
+