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