diff --git a/CHANGELOG.md b/CHANGELOG.md index 35503c0..e0c8d7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ # CodeClocker Changelog -## [Unreleased] +## [1.0.9] - 2025-11-16 + +- Add status bar widget with daily coding activity ## [1.0.8] - 2025-10-29 diff --git a/docs/img/demo.gif b/docs/img/demo.gif index a6ad657..447a5df 100644 Binary files a/docs/img/demo.gif and b/docs/img/demo.gif differ diff --git a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyLifecycle.java b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyLifecycle.java index 4f8659a..6e01381 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyLifecycle.java +++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/ApiKeyLifecycle.java @@ -63,10 +63,10 @@ public static String getActiveApiKey() { return null; } - boolean activitiDataStoppedBeingCollected = + boolean activityDataStoppedBeingCollected = PropertiesComponent.getInstance() .getBoolean(ACTIVITY_DATA_STOPPED_BEING_COLLECTED_PROPERTY, false); - if (activitiDataStoppedBeingCollected) { + if (activityDataStoppedBeingCollected) { LOG.warn("Activity data stopped being collected"); return null; } diff --git a/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java b/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java index c7f8bed..ff2b8d1 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java +++ b/src/main/java/com/codeclocker/plugin/intellij/apikey/EnterApiKeyAction.java @@ -14,6 +14,8 @@ public class EnterApiKeyAction extends AnAction { + private static final String ENTER_API_KEY = "Paste your API Key to connect to CodeClocker Hub."; + @Override public void actionPerformed(@NotNull AnActionEvent e) { showAction(); @@ -37,14 +39,14 @@ public static void showAction() { private static String getText() { String apiKey = ApiKeyPersistence.getApiKey(); if (isBlank(apiKey)) { - return """ - Enter your CodeClocker API Key to start tracking your coding activity."""; + return ENTER_API_KEY; } - return """ - Your current API key: %s + return (""" + Current key: %s - Enter your CodeClocker API Key to start tracking your coding activity.""" + """ + + ENTER_API_KEY) .formatted(apiKey); } diff --git a/src/main/java/com/codeclocker/plugin/intellij/reporting/DailyTimeHttpClient.java b/src/main/java/com/codeclocker/plugin/intellij/reporting/DailyTimeHttpClient.java index 9a43466..fed6fa8 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/reporting/DailyTimeHttpClient.java +++ b/src/main/java/com/codeclocker/plugin/intellij/reporting/DailyTimeHttpClient.java @@ -63,21 +63,23 @@ public DailyTimeResponse fetchDailyTimePerProject(String apiKey, ZoneId timeZone } } - private record DailyTimePerProjectDto(Map projects) {} + private record DailyTimePerProjectDto(Map projects) {} + + public record ProjectStats(long timeSpentSeconds, long additions, long removals) {} public static class DailyTimeResponse { - private final Map projects; + private final Map projects; private final boolean subscriptionExpired; private final boolean error; private DailyTimeResponse( - Map projects, boolean subscriptionExpired, boolean error) { + Map projects, boolean subscriptionExpired, boolean error) { this.projects = projects; this.subscriptionExpired = subscriptionExpired; this.error = error; } - public static DailyTimeResponse success(Map projects) { + public static DailyTimeResponse success(Map projects) { return new DailyTimeResponse(projects, false, false); } @@ -89,7 +91,7 @@ public static DailyTimeResponse error() { return new DailyTimeResponse(Collections.emptyMap(), false, true); } - public Map getProjects() { + public Map getProjects() { return projects; } diff --git a/src/main/java/com/codeclocker/plugin/intellij/services/ChangesActivityTracker.java b/src/main/java/com/codeclocker/plugin/intellij/services/ChangesActivityTracker.java index 1871c60..45d1eb1 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/services/ChangesActivityTracker.java +++ b/src/main/java/com/codeclocker/plugin/intellij/services/ChangesActivityTracker.java @@ -3,12 +3,16 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class ChangesActivityTracker { + public static final AtomicLong GLOBAL_ADDITIONS = new AtomicLong(0); + public static final AtomicLong GLOBAL_REMOVALS = new AtomicLong(0); + private final Map> fileNameByChangesSample = new ConcurrentHashMap<>(); private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock(); @@ -18,6 +22,8 @@ public void incrementAdditions( Lock lock = readWriteLock.readLock(); try { lock.lock(); + GLOBAL_ADDITIONS.addAndGet(additions); + fileNameByChangesSample .computeIfAbsent(project, p -> new ConcurrentHashMap<>()) .computeIfAbsent(filePath, f -> ChangesSample.create(extension)) @@ -31,6 +37,8 @@ public void incrementRemovals(String project, String fileName, String extension, Lock lock = readWriteLock.readLock(); try { lock.lock(); + GLOBAL_REMOVALS.addAndGet(removals); + fileNameByChangesSample .computeIfAbsent(project, p -> new ConcurrentHashMap<>()) .computeIfAbsent(fileName, f -> ChangesSample.create(extension)) 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 3b07fcf..e1a38c9 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,7 @@ package com.codeclocker.plugin.intellij.services; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_ADDITIONS; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_REMOVALS; import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_STOP_WATCH; import com.codeclocker.plugin.intellij.stopwatch.SafeStopWatch; @@ -25,8 +27,9 @@ public class TimeTrackerWidgetService implements Disposable { private final TimeTrackerWidget widget; private final AtomicLong initProjectTime = new AtomicLong(0); private final AtomicLong initTotalTime = new AtomicLong(0); - private LocalDate lastDate = LocalDate.now(); private final SafeStopWatch projectStopWatch = SafeStopWatch.createStopped(); + + private LocalDate lastDate = LocalDate.now(); private ScheduledFuture ticker; public TimeTrackerWidgetService(Project project) { @@ -106,7 +109,10 @@ private void checkMidnightReset() { initProjectTime.set(0); initTotalTime.set(0); projectStopWatch.reset(); + GLOBAL_ADDITIONS.set(0); + GLOBAL_REMOVALS.set(0); GLOBAL_STOP_WATCH.reset(); + lastDate = currentDate; } } 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 3497f7a..d964a5e 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,7 @@ package com.codeclocker.plugin.intellij.widget; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_ADDITIONS; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_REMOVALS; import static com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger.GLOBAL_STOP_WATCH; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -66,9 +68,9 @@ private static void fetchTimeAndInitialize() { return; } - Map projectTimes = response.getProjects(); - LOG.info("Fetched daily time for project count: " + projectTimes.size()); - initializeAllProjectWidgets(projectTimes, false); + Map projectStats = response.getProjects(); + LOG.info("Fetched daily time for project count: " + projectStats.size()); + initializeAllProjectWidgets(projectStats, false); } catch (Exception e) { LOG.error("Error initializing timer widgets", e); initializeAllProjectWidgets(Map.of(), false); @@ -76,17 +78,36 @@ private static void fetchTimeAndInitialize() { } private static void initializeAllProjectWidgets( - Map projectTimes, boolean subscriptionExpired) { - long totalTime = projectTimes.values().stream().mapToLong(Long::longValue).sum(); - LOG.debug("Total time across all projects: {}s", totalTime); + Map projectStats, boolean subscriptionExpired) { + long totalTime = + projectStats.values().stream() + .mapToLong(DailyTimeHttpClient.ProjectStats::timeSpentSeconds) + .sum(); + long totalAdditions = + projectStats.values().stream().mapToLong(DailyTimeHttpClient.ProjectStats::additions).sum(); + long totalRemovals = + projectStats.values().stream().mapToLong(DailyTimeHttpClient.ProjectStats::removals).sum(); + + LOG.debug( + "Total time across all projects: {}s, additions: {}, removals: {}", + totalTime, + totalAdditions, + totalRemovals); // Reset the global stopwatch since the backend total already includes all accumulated time GLOBAL_STOP_WATCH.reset(); + // Initialize global VCS counters with data from backend + GLOBAL_ADDITIONS.set(totalAdditions); + GLOBAL_REMOVALS.set(totalRemovals); + LOG.debug( + "Initialized GLOBAL_ADDITIONS: {}, GLOBAL_REMOVALS: {}", totalAdditions, totalRemovals); + Project[] openProjects = ProjectManager.getInstance().getOpenProjects(); for (Project project : openProjects) { String projectName = project.getName(); - long initialSeconds = projectTimes.getOrDefault(projectName, 0L); + DailyTimeHttpClient.ProjectStats stats = projectStats.get(projectName); + long initialSeconds = stats != null ? stats.timeSpentSeconds() : 0L; TimeTrackerWidgetService service = project.getService(TimeTrackerWidgetService.class); if (service != null) { diff --git a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java new file mode 100644 index 0000000..ba7d22e --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java @@ -0,0 +1,72 @@ +package com.codeclocker.plugin.intellij.widget; + +import static com.codeclocker.plugin.intellij.HubHost.HUB_UI_HOST; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_ADDITIONS; +import static com.codeclocker.plugin.intellij.services.ChangesActivityTracker.GLOBAL_REMOVALS; +import static org.apache.commons.lang3.StringUtils.isNotBlank; + +import com.codeclocker.plugin.intellij.apikey.ApiKeyPersistence; +import com.codeclocker.plugin.intellij.apikey.EnterApiKeyAction; +import com.intellij.ide.BrowserUtil; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.popup.JBPopupFactory; +import com.intellij.openapi.ui.popup.ListPopup; +import com.intellij.openapi.ui.popup.ListSeparator; +import com.intellij.openapi.ui.popup.PopupStep; +import com.intellij.openapi.ui.popup.util.BaseListPopupStep; +import java.util.ArrayList; +import java.util.List; +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"; + + public static ListPopup create(Project project, String totalTime, String projectTime) { + List items = new ArrayList<>(); + items.add("Total: " + totalTime); + items.add(project.getName() + ": " + projectTime); + items.add("Committed Lines: " + getFormattedVcsChanges()); + items.add(OPEN_DETAILED_VIEW); + + boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); + if (!hasApiKey) { + items.add(ADD_API_KEY); + } + + BaseListPopupStep step = + new BaseListPopupStep<>("Coding Activity Today", items) { + @Override + public boolean isSelectable(String value) { + return OPEN_DETAILED_VIEW.equals(value) || ADD_API_KEY.equals(value); + } + + @Override + public PopupStep onChosen(String selectedValue, boolean finalChoice) { + if (OPEN_DETAILED_VIEW.equals(selectedValue)) { + BrowserUtil.browse(HUB_UI_HOST); + } else if (ADD_API_KEY.equals(selectedValue)) { + EnterApiKeyAction.showAction(); + } + return FINAL_CHOICE; + } + + @Override + public boolean hasSubstep(String selectedValue) { + return false; + } + + @Override + public @Nullable ListSeparator getSeparatorAbove(String value) { + return OPEN_DETAILED_VIEW.equals(value) ? new ListSeparator() : null; + } + }; + + return JBPopupFactory.getInstance().createListPopup(step); + } + + public static String getFormattedVcsChanges() { + return String.format("+%d / -%d", GLOBAL_ADDITIONS.get(), GLOBAL_REMOVALS.get()); + } +} 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 d4d31f1..f23b3f7 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerWidget.java @@ -3,13 +3,13 @@ import com.codeclocker.plugin.intellij.services.TimeTrackerWidgetService; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.popup.JBPopup; +import com.intellij.openapi.ui.popup.ListPopup; import com.intellij.openapi.util.IconLoader; import com.intellij.openapi.wm.StatusBar; import com.intellij.openapi.wm.StatusBarWidget; import com.intellij.openapi.wm.WindowManager; -import com.intellij.util.Consumer; import com.intellij.util.IconUtil; -import java.awt.event.MouseEvent; import javax.swing.Icon; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,20 +19,24 @@ public class TimeTrackerWidget private static final Logger LOG = Logger.getInstance(TimeTrackerWidget.class); private static final String WIDGET_ID = "com.codeclocker.TimeTrackerWidget"; + + // todo: Prefer a small, monochrome SVG specifically for the status bar + // e.g., "/icons/status_time.svg" sized for 16px. For SVGs, the platform scales automatically. private static final Icon WIDGET_ICON = loadScaledIcon(); private final Project project; private final TimeTrackerWidgetService service; + private StatusBar statusBar; + private JBPopup popup; private static Icon loadScaledIcon() { - Icon icon = IconLoader.getIcon("/META-INF/pluginIcon.svg", TimeTrackerWidget.class); - // Scale to 16x16 for status bar + Icon icon = IconLoader.getIcon("/META-INF/statusBarWidgetIcon.svg", TimeTrackerWidget.class); return IconUtil.scale(icon, null, 16.0f / icon.getIconWidth()); } public TimeTrackerWidget(Project project, TimeTrackerWidgetService service) { - LOG.info("TimeTrackerWidget constructor called for project: " + project.getName()); + LOG.debug("TimeTrackerWidget constructor for project: " + project.getName()); this.project = project; this.service = service; } @@ -44,13 +48,15 @@ public TimeTrackerWidget(Project project, TimeTrackerWidgetService service) { @Override public void install(@NotNull StatusBar statusBar) { - LOG.info("TimeTrackerWidget install() called"); this.statusBar = statusBar; } @Override public void dispose() { - LOG.info("TimeTrackerWidget dispose() called"); + if (popup != null && !popup.isDisposed()) { + popup.cancel(); + popup = null; + } } @Override @@ -58,30 +64,20 @@ public void dispose() { return this; } - // MultipleTextValuesPresentation methods - @Nullable @Override public String getSelectedValue() { - if (project == null || service == null) { - return ""; - } String totalTime = service.getFormattedTotalTime(); String projectTime = service.getFormattedProjectTime(); return "Total: " + totalTime + " | Project: " + projectTime; } - @Nullable @Override public Icon getIcon() { return WIDGET_ICON; } - @Nullable @Override public String getTooltipText() { - if (project == null || service == null) { - return null; - } String totalTime = service.getFormattedTotalTime(); String projectTime = service.getFormattedProjectTime(); return "Total coding time today: " @@ -94,19 +90,20 @@ public String getTooltipText() { @Nullable @Override - public Consumer getClickConsumer() { - return null; + public ListPopup getPopup() { + String totalTime = service.getFormattedTotalTime(); + String projectTime = service.getFormattedProjectTime(); + + return TimeTrackerPopup.create(project, totalTime, projectTime); } public void updateText() { - // Notify the status bar to update this widget if (statusBar != null) { statusBar.updateWidget(WIDGET_ID); } else { - // Fallback: try to get status bar from WindowManager - StatusBar fallbackStatusBar = WindowManager.getInstance().getStatusBar(project); - if (fallbackStatusBar != null) { - fallbackStatusBar.updateWidget(WIDGET_ID); + StatusBar fb = WindowManager.getInstance().getStatusBar(project); + if (fb != null) { + fb.updateWidget(WIDGET_ID); } } } diff --git a/src/main/resources/META-INF/statusBarWidgetIcon.svg b/src/main/resources/META-INF/statusBarWidgetIcon.svg new file mode 100644 index 0000000..2ce94ca --- /dev/null +++ b/src/main/resources/META-INF/statusBarWidgetIcon.svg @@ -0,0 +1,9 @@ + + + + + + + + +