diff --git a/CHANGELOG.md b/CHANGELOG.md index 20bcb58..74ad3ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ ## [Unreleased] +## [1.9.0] - 2026-02-13 + +### Added + +- **In-IDE Dashboard** - New "Dashboard" tab in the CodeClocker tool window with a full analytics view: + - **Metric cards** - Total time, daily average, lines added, lines removed, and current/longest streak + - **Activity Timeline** - Interactive area chart with hover tooltips showing coding activity over time + - **Journey Bar** - Lifetime stats (days active, total time, projects, lines changed) + - **All Projects breakdown** - Paginated table showing per-project time spent, lines added, and lines removed with sorting by time descending + - **Time period selector** - Choose from 24h, 7d, 30d, This Week, or This Month + - **Trend indicator** - Percentage change compared to the previous period +- **Dashboard access from popup** - New "Dashboard..." option in the status bar popup to quickly open the Dashboard tab + +### Changed + +- **Tool window tabs** - Dashboard tab is now the first tab; Activity Report is the second tab +- **Popup menu separators** - Added visual separator between coding time trends and menu action buttons + ## [1.8.0] - 2026-02-12 ### Added @@ -182,7 +200,8 @@ - Support IntelliJ Platform 2024.3.5 -[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.8.0...HEAD +[Unreleased]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.9.0...HEAD +[1.9.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.8.0...v1.9.0 [1.8.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.7.0...v1.8.0 [1.7.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/codeclocker/codeclocker-intellij-plugin/compare/v1.5.2...v1.6.0 diff --git a/README.md b/README.md index b34de00..b757bf3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,17 @@ Enable Hub Mode with an API key to sync activity to **[CodeClocker Hub](https:// - **Time range filtering** (e.g., last 7 days, custom periods) - **Data storage** - Activity is synced to CodeClocker Hub only when you enable Hub Mode and provide an API key. +### Dashboard + +Click the status bar widget and select **Dashboard...** to open an in-IDE analytics dashboard: + +- **Metric cards** - Total time, daily average, lines added/removed, and streak counters +- **Activity Timeline** - Interactive area chart showing coding activity over time with hover tooltips +- **Journey Bar** - Lifetime stats including total days, time, projects, and lines changed +- **All Projects breakdown** - Paginated table of per-project time, additions, and removals +- **Time period selector** - Switch between 24h, 7d, 30d, this week, or this month +- **Trend indicator** - Percentage change vs. previous period + ### Activity Report Click the status bar widget and select **Activity Report...** to open a detailed view of your coding activity: diff --git a/gradle.properties b/gradle.properties index 3c05bc9..26d0d1c 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.8.0 +pluginVersion = 1.9.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 252 diff --git a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java index 7e191d1..179a9ed 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java +++ b/src/main/java/com/codeclocker/plugin/intellij/analytics/AnalyticsEventType.java @@ -14,6 +14,7 @@ private AnalyticsEventType() {} public static final String POPUP_SET_GOALS_CLICK = "popup_set_goals_click"; public static final String POPUP_SET_PROJECT_GOALS_CLICK = "popup_set_project_goals_click"; public static final String POPUP_AUTO_PAUSE_CLICK = "popup_auto_pause_click"; + public static final String POPUP_DASHBOARD_CLICK = "popup_dashboard_click"; public static final String POPUP_ACTIVITY_REPORT_CLICK = "popup_activity_report_click"; // Plugin lifecycle events diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardDataService.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardDataService.java new file mode 100644 index 0000000..b22a148 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardDataService.java @@ -0,0 +1,457 @@ +package com.codeclocker.plugin.intellij.dashboard; + +import com.codeclocker.plugin.intellij.local.LocalActivityDataProvider; +import com.codeclocker.plugin.intellij.local.ProjectActivitySnapshot; +import com.codeclocker.plugin.intellij.services.TimeSpentPerProjectLogger; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** Computes all dashboard metrics from local activity data. */ +@Service(Service.Level.APP) +public final class DashboardDataService { + + private static final DateTimeFormatter HOUR_KEY_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HH"); + + public enum TimePeriod { + LAST_24_HOURS("24h"), + LAST_7_DAYS("7d"), + LAST_30_DAYS("30d"), + THIS_WEEK("This Week"), + THIS_MONTH("This Month"); + + private final String label; + + TimePeriod(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + public record ProjectBreakdownEntry( + String projectName, long timeSpentSeconds, long additions, long removals) {} + + public record TimelineDataPoint(String label, long seconds) {} + + public record DashboardData( + long totalTimeSpent, + long dailyAverage, + long additions, + long removals, + int trendPercentage, + int currentStreak, + int longestStreak, + int lifetimeDays, + long lifetimeTimeSpent, + int lifetimeProjects, + long lifetimeLines, + LocalDate firstActivityDate) {} + + public DashboardData computeForPeriod(TimePeriod period) { + Map> allData = getAllDataWithUnsaved(); + + LocalDate today = LocalDate.now(); + LocalDate periodStart = getPeriodStart(period, today); + LocalDate periodEnd = getPeriodEnd(period, today); + + // Aggregate for current period + long totalTime = 0; + long totalAdditions = 0; + long totalRemovals = 0; + Set activeDays = new HashSet<>(); + + // For LAST_24_HOURS, we need hour-level filtering + LocalDateTime cutoff24h = + period == TimePeriod.LAST_24_HOURS ? LocalDateTime.now().minusHours(24) : null; + + for (Map.Entry> entry : allData.entrySet()) { + String hourKey = entry.getKey(); + if (!isInPeriod(hourKey, periodStart, periodEnd, cutoff24h)) { + continue; + } + String dateStr = extractDate(hourKey); + activeDays.add(dateStr); + for (ProjectActivitySnapshot snapshot : entry.getValue().values()) { + totalTime += snapshot.getCodedTimeSeconds(); + totalAdditions += snapshot.getAdditions(); + totalRemovals += snapshot.getRemovals(); + } + } + + int uniqueActiveDays = activeDays.size(); + long dailyAverage = uniqueActiveDays > 0 ? totalTime / uniqueActiveDays : 0; + + // Trend: compare with previous period of same length + int trendPercentage = computeTrend(allData, period, today, totalTime); + + // Streaks from all data + int[] streaks = computeStreaks(allData); + int currentStreak = streaks[0]; + int longestStreak = streaks[1]; + + // Lifetime stats from all data + Set lifetimeActiveDays = new HashSet<>(); + Set lifetimeProjects = new HashSet<>(); + long lifetimeTime = 0; + long lifetimeLines = 0; + LocalDate firstDate = null; + + for (Map.Entry> entry : allData.entrySet()) { + String dateStr = extractDate(entry.getKey()); + boolean hasActivity = false; + for (Map.Entry projEntry : entry.getValue().entrySet()) { + ProjectActivitySnapshot snapshot = projEntry.getValue(); + if (snapshot.getCodedTimeSeconds() > 0 + || snapshot.getAdditions() > 0 + || snapshot.getRemovals() > 0) { + hasActivity = true; + lifetimeProjects.add(projEntry.getKey()); + } + lifetimeTime += snapshot.getCodedTimeSeconds(); + lifetimeLines += snapshot.getAdditions() + snapshot.getRemovals(); + } + if (hasActivity) { + lifetimeActiveDays.add(dateStr); + try { + LocalDate date = LocalDate.parse(dateStr); + if (firstDate == null || date.isBefore(firstDate)) { + firstDate = date; + } + } catch (Exception ignored) { + } + } + } + + return new DashboardData( + totalTime, + dailyAverage, + totalAdditions, + totalRemovals, + trendPercentage, + currentStreak, + longestStreak, + lifetimeActiveDays.size(), + lifetimeTime, + lifetimeProjects.size(), + lifetimeLines, + firstDate); + } + + public List computeTimelineData(TimePeriod period) { + Map> allData = getAllDataWithUnsaved(); + LocalDate today = LocalDate.now(); + + if (period == TimePeriod.LAST_24_HOURS) { + return computeHourlyTimeline(allData); + } + return computeDailyTimeline( + allData, getPeriodStart(period, today), getPeriodEnd(period, today)); + } + + public List computeProjectBreakdown(TimePeriod period) { + Map> allData = getAllDataWithUnsaved(); + LocalDate today = LocalDate.now(); + LocalDate periodStart = getPeriodStart(period, today); + LocalDate periodEnd = getPeriodEnd(period, today); + + LocalDateTime cutoff24h = + period == TimePeriod.LAST_24_HOURS ? LocalDateTime.now().minusHours(24) : null; + + // Accumulate per project: [timeSpent, additions, removals] + Map perProject = new LinkedHashMap<>(); + + for (Map.Entry> entry : allData.entrySet()) { + String hourKey = entry.getKey(); + if (!isInPeriod(hourKey, periodStart, periodEnd, cutoff24h)) { + continue; + } + for (Map.Entry projEntry : entry.getValue().entrySet()) { + String projectName = projEntry.getKey(); + ProjectActivitySnapshot snapshot = projEntry.getValue(); + long[] acc = perProject.computeIfAbsent(projectName, k -> new long[3]); + acc[0] += snapshot.getCodedTimeSeconds(); + acc[1] += snapshot.getAdditions(); + acc[2] += snapshot.getRemovals(); + } + } + + // Filter zero-activity, sort by time descending + List result = new ArrayList<>(); + for (Map.Entry entry : perProject.entrySet()) { + long[] acc = entry.getValue(); + if (acc[0] > 0 || acc[1] > 0 || acc[2] > 0) { + result.add(new ProjectBreakdownEntry(entry.getKey(), acc[0], acc[1], acc[2])); + } + } + result.sort((a, b) -> Long.compare(b.timeSpentSeconds(), a.timeSpentSeconds())); + return result; + } + + private List computeHourlyTimeline( + Map> allData) { + LocalDateTime now = LocalDateTime.now().truncatedTo(java.time.temporal.ChronoUnit.HOURS); + DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("HH:00"); + + // Build ordered map for last 24 hours + Map hourlyMap = new LinkedHashMap<>(); + for (int i = 23; i >= 0; i--) { + LocalDateTime hour = now.minusHours(i); + hourlyMap.put(hour.format(HOUR_KEY_FORMATTER), 0L); + } + + // Sum data into matching hours + for (Map.Entry> entry : allData.entrySet()) { + String hourKey = entry.getKey(); + if (hourlyMap.containsKey(hourKey)) { + long sum = 0; + for (ProjectActivitySnapshot snapshot : entry.getValue().values()) { + sum += snapshot.getCodedTimeSeconds(); + } + hourlyMap.merge(hourKey, sum, Long::sum); + } + } + + // Convert to data points with display labels + List points = new ArrayList<>(); + for (Map.Entry entry : hourlyMap.entrySet()) { + try { + LocalDateTime hour = LocalDateTime.parse(entry.getKey(), HOUR_KEY_FORMATTER); + points.add(new TimelineDataPoint(hour.format(labelFormatter), entry.getValue())); + } catch (Exception ignored) { + } + } + return points; + } + + private List computeDailyTimeline( + Map> allData, LocalDate start, LocalDate end) { + DateTimeFormatter labelFormatter = DateTimeFormatter.ofPattern("MMM d"); + + // Build ordered map for each day in range + Map dailyMap = new LinkedHashMap<>(); + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + dailyMap.put(d.toString(), 0L); + } + + // Sum data into matching days + for (Map.Entry> entry : allData.entrySet()) { + String dateStr = extractDate(entry.getKey()); + if (dailyMap.containsKey(dateStr)) { + long sum = 0; + for (ProjectActivitySnapshot snapshot : entry.getValue().values()) { + sum += snapshot.getCodedTimeSeconds(); + } + dailyMap.merge(dateStr, sum, Long::sum); + } + } + + // Convert to data points with display labels + List points = new ArrayList<>(); + for (Map.Entry entry : dailyMap.entrySet()) { + try { + LocalDate date = LocalDate.parse(entry.getKey()); + points.add(new TimelineDataPoint(date.format(labelFormatter), entry.getValue())); + } catch (Exception ignored) { + } + } + return points; + } + + private Map> getAllDataWithUnsaved() { + LocalActivityDataProvider dataProvider = + ApplicationManager.getApplication().getService(LocalActivityDataProvider.class); + if (dataProvider == null) { + return Collections.emptyMap(); + } + + Map> allData = + new LinkedHashMap<>(dataProvider.getAllDataInLocalTimezone()); + mergeUnsavedDeltas(allData); + return allData; + } + + private void mergeUnsavedDeltas(Map> allData) { + TimeSpentPerProjectLogger logger = + ApplicationManager.getApplication().getService(TimeSpentPerProjectLogger.class); + if (logger == null) { + return; + } + + String currentHourKey = LocalDateTime.now().format(HOUR_KEY_FORMATTER); + Map hourData = + allData.computeIfAbsent(currentHourKey, k -> new LinkedHashMap<>()); + + Set projectNames = new HashSet<>(); + for (Map hourEntry : allData.values()) { + projectNames.addAll(hourEntry.keySet()); + } + + for (String projectName : projectNames) { + long unsavedDelta = logger.getProjectUnsavedDelta(projectName); + if (unsavedDelta > 0) { + ProjectActivitySnapshot existing = hourData.get(projectName); + if (existing != null) { + ProjectActivitySnapshot updated = + new ProjectActivitySnapshot( + existing.getCodedTimeSeconds() + unsavedDelta, + existing.getAdditions(), + existing.getRemovals(), + existing.isReported()); + updated.setBranchActivity(existing.getBranchActivity()); + updated.setCommits(existing.getCommits()); + hourData.put(projectName, updated); + } else { + hourData.put(projectName, new ProjectActivitySnapshot(unsavedDelta, 0, 0, false)); + } + } + } + } + + private LocalDate getPeriodStart(TimePeriod period, LocalDate today) { + return switch (period) { + case LAST_24_HOURS -> today.minusDays(1); + case LAST_7_DAYS -> today.minusDays(6); + case LAST_30_DAYS -> today.minusDays(29); + case THIS_WEEK -> today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + case THIS_MONTH -> today.withDayOfMonth(1); + }; + } + + private LocalDate getPeriodEnd(TimePeriod period, LocalDate today) { + return today; + } + + private boolean isInPeriod( + String hourKey, LocalDate start, LocalDate end, LocalDateTime cutoff24h) { + try { + if (cutoff24h != null) { + LocalDateTime hourDateTime = LocalDateTime.parse(hourKey, HOUR_KEY_FORMATTER); + return !hourDateTime.isBefore(cutoff24h) && !hourDateTime.isAfter(LocalDateTime.now()); + } + String dateStr = hourKey.substring(0, 10); + LocalDate date = LocalDate.parse(dateStr); + return !date.isBefore(start) && !date.isAfter(end); + } catch (Exception e) { + return false; + } + } + + private int computeTrend( + Map> allData, + TimePeriod period, + LocalDate today, + long currentTotal) { + + LocalDate currentStart = getPeriodStart(period, today); + long periodDays = java.time.temporal.ChronoUnit.DAYS.between(currentStart, today) + 1; + + LocalDate prevEnd = currentStart.minusDays(1); + LocalDate prevStart = prevEnd.minusDays(periodDays - 1); + + // For LAST_24_HOURS, compare previous 24h + LocalDateTime prevCutoff = + period == TimePeriod.LAST_24_HOURS ? LocalDateTime.now().minusHours(48) : null; + LocalDateTime prevEndTime = + period == TimePeriod.LAST_24_HOURS ? LocalDateTime.now().minusHours(24) : null; + + long prevTotal = 0; + for (Map.Entry> entry : allData.entrySet()) { + String hourKey = entry.getKey(); + boolean inPrev; + if (prevCutoff != null) { + try { + LocalDateTime hourDateTime = LocalDateTime.parse(hourKey, HOUR_KEY_FORMATTER); + inPrev = !hourDateTime.isBefore(prevCutoff) && hourDateTime.isBefore(prevEndTime); + } catch (Exception e) { + inPrev = false; + } + } else { + inPrev = isInPeriod(hourKey, prevStart, prevEnd, null); + } + + if (inPrev) { + for (ProjectActivitySnapshot snapshot : entry.getValue().values()) { + prevTotal += snapshot.getCodedTimeSeconds(); + } + } + } + + return calculatePercentageChange(currentTotal, prevTotal); + } + + private static int calculatePercentageChange(long current, long previous) { + if (previous == 0) { + return current > 0 ? 100 : 0; + } + return (int) Math.round(((double) (current - previous) / previous) * 100); + } + + private int[] computeStreaks(Map> allData) { + TreeSet activeDates = new TreeSet<>(); + for (Map.Entry> entry : allData.entrySet()) { + String dateStr = extractDate(entry.getKey()); + boolean hasTime = + entry.getValue().values().stream().anyMatch(s -> s.getCodedTimeSeconds() > 0); + if (hasTime) { + try { + activeDates.add(LocalDate.parse(dateStr)); + } catch (Exception ignored) { + } + } + } + + if (activeDates.isEmpty()) { + return new int[] {0, 0}; + } + + List sorted = new ArrayList<>(activeDates); + + // Current streak: consecutive days ending today or yesterday + int currentStreak = 0; + LocalDate checkDate = LocalDate.now(); + if (!activeDates.contains(checkDate)) { + checkDate = checkDate.minusDays(1); + } + while (activeDates.contains(checkDate)) { + currentStreak++; + checkDate = checkDate.minusDays(1); + } + + // Longest streak + int longestStreak = 1; + int runLength = 1; + for (int i = 1; i < sorted.size(); i++) { + if (sorted.get(i).equals(sorted.get(i - 1).plusDays(1))) { + runLength++; + longestStreak = Math.max(longestStreak, runLength); + } else { + runLength = 1; + } + } + + return new int[] {currentStreak, longestStreak}; + } + + private String extractDate(String hourKey) { + if (hourKey != null && hourKey.length() >= 10) { + return hourKey.substring(0, 10); + } + return hourKey; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardPanel.java new file mode 100644 index 0000000..9a30e08 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/DashboardPanel.java @@ -0,0 +1,232 @@ +package com.codeclocker.plugin.intellij.dashboard; + +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.DashboardData; +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.ProjectBreakdownEntry; +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.TimePeriod; +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.TimelineDataPoint; +import com.codeclocker.plugin.intellij.dashboard.ui.ActivityTimelinePanel; +import com.codeclocker.plugin.intellij.dashboard.ui.AllProjectsPanel; +import com.codeclocker.plugin.intellij.dashboard.ui.JourneyBarPanel; +import com.codeclocker.plugin.intellij.dashboard.ui.MetricCardPanel; +import com.codeclocker.plugin.intellij.dashboard.ui.StreakCardPanel; +import com.codeclocker.plugin.intellij.dashboard.ui.TimePeriodSelectorPanel; +import com.intellij.icons.AllIcons; +import com.intellij.openapi.Disposable; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.actionSystem.ActionToolbar; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.ui.JBColor; +import com.intellij.ui.components.JBScrollPane; +import com.intellij.util.ui.JBUI; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.GridLayout; +import java.util.List; +import javax.swing.BoxLayout; +import javax.swing.JPanel; +import javax.swing.event.AncestorEvent; +import javax.swing.event.AncestorListener; +import org.jetbrains.annotations.NotNull; + +/** Main dashboard panel displayed as a tab in the CodeClocker tool window. */ +public class DashboardPanel extends JPanel implements Disposable { + + private static final Color PURPLE = new JBColor(0x7C3AED, 0xA78BFA); + private static final Color CYAN = new JBColor(0x0891B2, 0x22D3EE); + private static final Color GREEN = new JBColor(0x16A34A, 0x4ADE80); + private static final Color RED = new JBColor(0xDC2626, 0xF87171); + + private final TimePeriodSelectorPanel periodSelector; + private final MetricCardPanel totalTimeCard; + private final MetricCardPanel dailyAvgCard; + private final MetricCardPanel linesAddedCard; + private final MetricCardPanel linesRemovedCard; + private final StreakCardPanel streakCard; + private final JourneyBarPanel journeyBar; + private final ActivityTimelinePanel activityTimeline; + private final AllProjectsPanel allProjectsPanel; + + public DashboardPanel() { + setLayout(new BorderLayout()); + + // Header: period selector + refresh button + JPanel headerPanel = new JPanel(new BorderLayout()); + periodSelector = new TimePeriodSelectorPanel(this::onPeriodChanged); + headerPanel.add(periodSelector, BorderLayout.CENTER); + + DefaultActionGroup actionGroup = new DefaultActionGroup(); + actionGroup.add( + new AnAction("Refresh", "Refresh dashboard data", AllIcons.Actions.Refresh) { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + refreshData(); + } + }); + ActionToolbar toolbar = + ActionManager.getInstance().createActionToolbar("DashboardToolbar", actionGroup, true); + toolbar.setTargetComponent(this); + headerPanel.add(toolbar.getComponent(), BorderLayout.EAST); + + add(headerPanel, BorderLayout.NORTH); + + // Scrollable content + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBorder(JBUI.Borders.empty(8, 12)); + + // Cards row + JPanel cardsRow = new JPanel(new GridLayout(1, 5, JBUI.scale(8), 0)); + + totalTimeCard = new MetricCardPanel("Total Time", PURPLE); + dailyAvgCard = new MetricCardPanel("Daily Average", CYAN); + linesAddedCard = new MetricCardPanel("Lines Added", GREEN); + linesRemovedCard = new MetricCardPanel("Lines Removed", RED); + streakCard = new StreakCardPanel(); + + cardsRow.add(totalTimeCard); + cardsRow.add(dailyAvgCard); + cardsRow.add(linesAddedCard); + cardsRow.add(linesRemovedCard); + cardsRow.add(streakCard); + + cardsRow.setMaximumSize( + new java.awt.Dimension(Integer.MAX_VALUE, cardsRow.getPreferredSize().height)); + contentPanel.add(cardsRow); + contentPanel.add(javax.swing.Box.createVerticalStrut(JBUI.scale(8))); + + // Journey bar + journeyBar = new JourneyBarPanel(); + journeyBar.setMaximumSize( + new java.awt.Dimension(Integer.MAX_VALUE, journeyBar.getPreferredSize().height)); + contentPanel.add(journeyBar); + contentPanel.add(javax.swing.Box.createVerticalStrut(JBUI.scale(8))); + + // Activity timeline chart + activityTimeline = new ActivityTimelinePanel(); + activityTimeline.setMaximumSize( + new java.awt.Dimension(Integer.MAX_VALUE, activityTimeline.getPreferredSize().height)); + contentPanel.add(activityTimeline); + contentPanel.add(javax.swing.Box.createVerticalStrut(JBUI.scale(8))); + + // All Projects breakdown table + allProjectsPanel = new AllProjectsPanel(); + allProjectsPanel.setMaximumSize(new java.awt.Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE)); + contentPanel.add(allProjectsPanel); + + // Push everything to the top + contentPanel.add(javax.swing.Box.createVerticalGlue()); + + JBScrollPane scrollPane = new JBScrollPane(contentPanel); + scrollPane.setBorder(JBUI.Borders.empty()); + add(scrollPane, BorderLayout.CENTER); + + // Refresh on visibility + addAncestorListener( + new AncestorListener() { + @Override + public void ancestorAdded(AncestorEvent event) { + refreshData(); + } + + @Override + public void ancestorRemoved(AncestorEvent event) {} + + @Override + public void ancestorMoved(AncestorEvent event) {} + }); + + // Initial load + refreshData(); + } + + private void onPeriodChanged(TimePeriod period) { + refreshData(); + } + + private void refreshData() { + totalTimeCard.setLoading(true); + dailyAvgCard.setLoading(true); + linesAddedCard.setLoading(true); + linesRemovedCard.setLoading(true); + + ApplicationManager.getApplication() + .executeOnPooledThread( + () -> { + DashboardDataService service = + ApplicationManager.getApplication().getService(DashboardDataService.class); + if (service == null) { + return; + } + + TimePeriod period = periodSelector.getSelected(); + DashboardData data = service.computeForPeriod(period); + List timelineData = service.computeTimelineData(period); + List breakdown = service.computeProjectBreakdown(period); + + ApplicationManager.getApplication() + .invokeLater( + () -> { + totalTimeCard.update( + formatTimeWithSeconds(data.totalTimeSpent()), + data.trendPercentage(), + null); + dailyAvgCard.update(formatTime(data.dailyAverage()), null, "per day"); + linesAddedCard.update("+" + formatNumber(data.additions()), null, null); + linesRemovedCard.update("-" + formatNumber(data.removals()), null, null); + streakCard.update(data.currentStreak(), data.longestStreak()); + journeyBar.update( + data.lifetimeDays(), + data.lifetimeTimeSpent(), + data.lifetimeProjects(), + data.lifetimeLines(), + data.firstActivityDate()); + activityTimeline.update(timelineData, period); + allProjectsPanel.update(breakdown); + }); + }); + } + + private String formatTimeWithSeconds(long seconds) { + if (seconds <= 0) { + return "0m"; + } + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + long secs = seconds % 60; + if (hours > 0) { + return hours + "h " + minutes + "m " + secs + "s"; + } + if (minutes > 0) { + return minutes + "m " + secs + "s"; + } + return secs + "s"; + } + + private String formatTime(long seconds) { + if (seconds <= 0) { + return "0m"; + } + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } + + private String formatNumber(long value) { + if (value >= 1_000_000) { + return String.format("%.1fM", value / 1_000_000.0); + } + if (value >= 1_000) { + return String.format("%.1fk", value / 1_000.0); + } + return String.valueOf(value); + } + + @Override + public void dispose() {} +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/ActivityTimelinePanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/ActivityTimelinePanel.java new file mode 100644 index 0000000..aba39a1 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/ActivityTimelinePanel.java @@ -0,0 +1,421 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.TimePeriod; +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.TimelineDataPoint; +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; +import java.awt.geom.GeneralPath; +import java.util.ArrayList; +import java.util.List; + +/** Custom area chart panel that visualizes coding activity over time. */ +public class ActivityTimelinePanel extends javax.swing.JPanel { + + private static final Color PRIMARY = new JBColor(0x7C3AED, 0xA78BFA); + private static final Color MUTED_TEXT = new JBColor(0x5E6687, 0xA9B1D6); + private static final Color GRID_LINE = new JBColor(0xE0E0E0, 0x3C3F41); + + private List dataPoints = new ArrayList<>(); + private boolean hourlyMode = true; + + private int[] px = new int[0]; + private int[] py = new int[0]; + private int chartX; + private int chartY; + private int chartWidth; + private int chartHeight; + private long yMax; + private int hoveredIndex = -1; + + public ActivityTimelinePanel() { + setOpaque(false); + setBorder( + JBUI.Borders.compound( + new MetricCardPanel.RoundedBorder( + JBColor.namedColor("Borders.color", new JBColor(0xD0D0D0, 0x505050))), + JBUI.Borders.empty(14, 14))); + + addMouseMotionListener( + new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + updateHoveredIndex(e.getX(), e.getY()); + } + }); + addMouseListener( + new MouseAdapter() { + @Override + public void mouseExited(MouseEvent e) { + if (hoveredIndex != -1) { + hoveredIndex = -1; + repaint(); + } + } + }); + } + + private void updateHoveredIndex(int mx, int my) { + if (px.length == 0 + || mx < chartX + || mx > chartX + chartWidth + || my < chartY + || my > chartY + chartHeight) { + if (hoveredIndex != -1) { + hoveredIndex = -1; + repaint(); + } + return; + } + int closest = -1; + int minDist = Integer.MAX_VALUE; + for (int i = 0; i < px.length; i++) { + int dist = Math.abs(mx - px[i]); + if (dist < minDist) { + minDist = dist; + closest = i; + } + } + int halfStep = px.length > 1 ? Math.abs(px[1] - px[0]) / 2 : chartWidth / 2; + if (minDist > halfStep) { + closest = -1; + } + if (closest != hoveredIndex) { + hoveredIndex = closest; + repaint(); + } + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(super.getPreferredSize().width, JBUI.scale(280)); + } + + public void update(List points, TimePeriod period) { + this.dataPoints = points != null ? points : new ArrayList<>(); + this.hourlyMode = period == TimePeriod.LAST_24_HOURS; + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint( + RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + // Background + g2.setColor(getBackground()); + int arc = JBUI.scale(10); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + + java.awt.Insets insets = getInsets(); + int x0 = insets.left; + int y0 = insets.top; + int totalWidth = getWidth() - insets.left - insets.right; + int totalHeight = getHeight() - insets.top - insets.bottom; + + // Title area + int titleY = y0; + Font boldFont = JBUI.Fonts.label().asBold().deriveFont((float) JBUI.scale(13)); + Font smallFont = JBUI.Fonts.smallFont(); + + g2.setFont(boldFont); + g2.setColor(getForeground()); + g2.drawString("Activity Timeline", x0, titleY + g2.getFontMetrics().getAscent()); + titleY += g2.getFontMetrics().getHeight() + JBUI.scale(2); + + g2.setFont(smallFont); + g2.setColor(MUTED_TEXT); + g2.drawString( + "Your coding activity over the selected period", + x0, + titleY + g2.getFontMetrics().getAscent()); + titleY += g2.getFontMetrics().getHeight() + JBUI.scale(10); + + // Chart margins + int leftMargin = JBUI.scale(55); + int bottomMargin = JBUI.scale(40); + int rightMargin = JBUI.scale(10); + int topMargin = JBUI.scale(5); + + chartX = x0 + leftMargin; + chartY = titleY + topMargin; + chartWidth = totalWidth - leftMargin - rightMargin; + chartHeight = y0 + totalHeight - chartY - bottomMargin; + + if (chartWidth <= 0 || chartHeight <= 0) { + g2.dispose(); + return; + } + + // Empty state + if (dataPoints.isEmpty() || dataPoints.stream().allMatch(p -> p.seconds() == 0)) { + px = new int[0]; + py = new int[0]; + g2.setFont(JBUI.Fonts.label()); + g2.setColor(MUTED_TEXT); + String msg = "No activity data for the selected period"; + int msgWidth = g2.getFontMetrics().stringWidth(msg); + g2.drawString(msg, chartX + (chartWidth - msgWidth) / 2, chartY + chartHeight / 2); + g2.dispose(); + return; + } + + // Y-axis scale + long maxData = dataPoints.stream().mapToLong(TimelineDataPoint::seconds).max().orElse(0); + long[] ticks; + + if (hourlyMode) { + yMax = 3600; + ticks = new long[] {900, 1800, 2700, 3600}; + } else { + if (maxData <= 28800) { + yMax = 28800; + ticks = new long[] {7200, 14400, 21600, 28800}; + } else if (maxData <= 43200) { + yMax = 43200; + ticks = new long[] {10800, 21600, 32400, 43200}; + } else if (maxData <= 57600) { + yMax = 57600; + ticks = new long[] {14400, 28800, 43200, 57600}; + } else if (maxData <= 72000) { + yMax = 72000; + ticks = new long[] {18000, 36000, 54000, 72000}; + } else { + yMax = 86400; + ticks = new long[] {21600, 43200, 64800, 86400}; + } + } + + // Draw horizontal grid lines + Y-axis labels + g2.setFont(smallFont); + BasicStroke dashedStroke = + new BasicStroke( + 1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[] {4, 4}, 0.0f); + BasicStroke solidStroke = new BasicStroke(1); + + // Baseline + g2.setStroke(solidStroke); + g2.setColor(GRID_LINE); + g2.drawLine(chartX, chartY + chartHeight, chartX + chartWidth, chartY + chartHeight); + + for (long tick : ticks) { + int ty = chartY + chartHeight - (int) ((double) tick / yMax * chartHeight); + g2.setStroke(dashedStroke); + g2.setColor(GRID_LINE); + g2.drawLine(chartX, ty, chartX + chartWidth, ty); + + g2.setStroke(solidStroke); + g2.setColor(MUTED_TEXT); + String label = formatYLabel(tick); + int labelWidth = g2.getFontMetrics().stringWidth(label); + g2.drawString( + label, chartX - labelWidth - JBUI.scale(6), ty + g2.getFontMetrics().getAscent() / 2); + } + + // Draw "0" at baseline + g2.setColor(MUTED_TEXT); + String zeroLabel = "0"; + int zeroWidth = g2.getFontMetrics().stringWidth(zeroLabel); + g2.drawString( + zeroLabel, + chartX - zeroWidth - JBUI.scale(6), + chartY + chartHeight + g2.getFontMetrics().getAscent() / 2); + + // Compute data point positions + int n = dataPoints.size(); + px = new int[n]; + py = new int[n]; + + for (int i = 0; i < n; i++) { + px[i] = chartX + (n > 1 ? (int) ((double) i / (n - 1) * chartWidth) : chartWidth / 2); + double ratio = Math.min((double) dataPoints.get(i).seconds() / yMax, 1.0); + py[i] = chartY + chartHeight - (int) (ratio * chartHeight); + } + + // Build smooth curve path + GeneralPath curvePath = new GeneralPath(); + curvePath.moveTo(px[0], py[0]); + + if (n == 1) { + // Single point - just a dot, no curve + } else if (n == 2) { + curvePath.lineTo(px[1], py[1]); + } else { + for (int i = 0; i < n - 1; i++) { + double midX = (px[i] + px[i + 1]) / 2.0; + curvePath.curveTo(midX, py[i], midX, py[i + 1], px[i + 1], py[i + 1]); + } + } + + // Gradient fill area + if (n > 1) { + GeneralPath areaPath = (GeneralPath) curvePath.clone(); + areaPath.lineTo(px[n - 1], chartY + chartHeight); + areaPath.lineTo(px[0], chartY + chartHeight); + areaPath.closePath(); + + Color primaryResolved = PRIMARY; + Color topColor = + new Color( + primaryResolved.getRed(), primaryResolved.getGreen(), primaryResolved.getBlue(), 102); + Color bottomColor = + new Color( + primaryResolved.getRed(), primaryResolved.getGreen(), primaryResolved.getBlue(), 13); + GradientPaint gradient = + new GradientPaint(0, chartY, topColor, 0, chartY + chartHeight, bottomColor); + g2.setPaint(gradient); + g2.fill(areaPath); + } + + // Draw curve line + if (n > 1) { + g2.setColor(PRIMARY); + g2.setStroke(new BasicStroke(JBUI.scale(2), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g2.draw(curvePath); + } + + // Data dots (only when <= 31 points) + if (n <= 31) { + int dotRadius = JBUI.scale(3); + g2.setColor(PRIMARY); + for (int i = 0; i < n; i++) { + if (dataPoints.get(i).seconds() > 0) { + g2.fillOval(px[i] - dotRadius, py[i] - dotRadius, dotRadius * 2, dotRadius * 2); + } + } + } + + // X-axis labels + g2.setFont(smallFont); + g2.setColor(MUTED_TEXT); + int labelInterval = computeLabelInterval(n, chartWidth, g2); + int xLabelY = chartY + chartHeight + JBUI.scale(16); + + for (int i = 0; i < n; i += labelInterval) { + String label = dataPoints.get(i).label(); + int labelWidth = g2.getFontMetrics().stringWidth(label); + g2.drawString(label, px[i] - labelWidth / 2, xLabelY); + } + + // Hover tooltip overlay + if (hoveredIndex >= 0 && hoveredIndex < n) { + paintTooltip(g2, boldFont, smallFont); + } + + g2.dispose(); + } + + private void paintTooltip(Graphics2D g2, Font boldFont, Font smallFont) { + int hx = px[hoveredIndex]; + int hy = py[hoveredIndex]; + + // Vertical crosshair line + Color mutedResolved = MUTED_TEXT; + g2.setColor( + new Color(mutedResolved.getRed(), mutedResolved.getGreen(), mutedResolved.getBlue(), 102)); + g2.setStroke( + new BasicStroke( + 1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[] {4, 4}, 0.0f)); + g2.drawLine(hx, chartY, hx, chartY + chartHeight); + + // Highlighted dot + int dotR = JBUI.scale(5); + g2.setStroke(new BasicStroke(JBUI.scale(2))); + g2.setColor(getBackground()); + g2.fillOval(hx - dotR, hy - dotR, dotR * 2, dotR * 2); + g2.setColor(PRIMARY); + g2.drawOval(hx - dotR, hy - dotR, dotR * 2, dotR * 2); + g2.fillOval(hx - JBUI.scale(3), hy - JBUI.scale(3), JBUI.scale(6), JBUI.scale(6)); + + // Tooltip box + String line1 = dataPoints.get(hoveredIndex).label(); + String line2 = formatTooltipValue(dataPoints.get(hoveredIndex).seconds()); + + g2.setFont(boldFont); + FontMetrics fmBold = g2.getFontMetrics(); + int line1Width = fmBold.stringWidth(line1); + int line1Height = fmBold.getHeight(); + + g2.setFont(smallFont); + FontMetrics fmSmall = g2.getFontMetrics(); + int line2Width = fmSmall.stringWidth(line2); + int line2Height = fmSmall.getHeight(); + + int pad = JBUI.scale(6); + int gap = JBUI.scale(2); + int boxWidth = Math.max(line1Width, line2Width) + pad * 2; + int boxHeight = line1Height + gap + line2Height + pad * 2; + + int boxX = hx - boxWidth / 2; + int boxY = hy - boxHeight - JBUI.scale(12); + + // Clamp horizontally + if (boxX < chartX) { + boxX = chartX; + } else if (boxX + boxWidth > chartX + chartWidth) { + boxX = chartX + chartWidth - boxWidth; + } + // If too close to top, show below + if (boxY < chartY) { + boxY = hy + JBUI.scale(12); + } + + int boxArc = JBUI.scale(6); + g2.setColor(getBackground()); + g2.fillRoundRect(boxX, boxY, boxWidth, boxHeight, boxArc, boxArc); + g2.setStroke(new BasicStroke(1)); + g2.setColor(GRID_LINE); + g2.drawRoundRect(boxX, boxY, boxWidth, boxHeight, boxArc, boxArc); + + g2.setFont(boldFont); + g2.setColor(getForeground()); + g2.drawString(line1, boxX + pad, boxY + pad + fmBold.getAscent()); + + g2.setFont(smallFont); + g2.setColor(MUTED_TEXT); + g2.drawString(line2, boxX + pad, boxY + pad + line1Height + gap + fmSmall.getAscent()); + } + + private String formatTooltipValue(long seconds) { + if (seconds <= 0) return "No activity"; + long h = seconds / 3600; + long m = (seconds % 3600) / 60; + long s = seconds % 60; + if (h > 0) return h + "h " + m + "m " + s + "s"; + if (m > 0) return m + "m " + s + "s"; + return s + "s"; + } + + private int computeLabelInterval(int n, int chartWidth, Graphics2D g2) { + if (n <= 1) return 1; + int avgLabelWidth = g2.getFontMetrics().stringWidth("MMM 00") + JBUI.scale(10); + int maxLabels = Math.max(1, chartWidth / avgLabelWidth); + return Math.max(1, (int) Math.ceil((double) n / maxLabels)); + } + + private String formatYLabel(long seconds) { + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0 && minutes == 0) { + return hours + "h"; + } + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/AllProjectsPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/AllProjectsPanel.java new file mode 100644 index 0000000..6f88f62 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/AllProjectsPanel.java @@ -0,0 +1,336 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.ProjectBreakdownEntry; +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; + +/** Paginated table showing per-project breakdown of time, additions and removals. */ +public class AllProjectsPanel extends JPanel { + + private static final Color GREEN = new JBColor(0x16A34A, 0x4ADE80); + private static final Color RED = new JBColor(0xDC2626, 0xF87171); + private static final Color MUTED_TEXT = new JBColor(0x5E6687, 0xA9B1D6); + private static final Color ALT_ROW_BG = + JBColor.namedColor("Table.alternativeRowBackground", new JBColor(0xF5F5F5, 0x2D2F31)); + + private final JPanel tableContainer; + private final JComboBox rowsPerPageCombo; + private final JLabel pageInfoLabel; + private final JLabel prevButton; + private final JLabel nextButton; + + private List allEntries = new ArrayList<>(); + private int currentPage = 0; + private int rowsPerPage = 10; + + public AllProjectsPanel() { + setLayout(new BorderLayout()); + setOpaque(false); + setBorder( + JBUI.Borders.compound( + new MetricCardPanel.RoundedBorder( + JBColor.namedColor("Borders.color", new JBColor(0xD0D0D0, 0x505050))), + JBUI.Borders.empty(14, 14))); + + // NORTH: section header + JPanel headerPanel = new JPanel(); + headerPanel.setLayout(new BoxLayout(headerPanel, BoxLayout.Y_AXIS)); + headerPanel.setOpaque(false); + headerPanel.setBorder(JBUI.Borders.emptyBottom(10)); + + JLabel titleLabel = new JLabel("All Projects"); + titleLabel.setFont(JBUI.Fonts.label().asBold().deriveFont((float) JBUI.scale(13))); + titleLabel.setAlignmentX(LEFT_ALIGNMENT); + headerPanel.add(titleLabel); + + headerPanel.add(Box.createVerticalStrut(JBUI.scale(2))); + + JLabel subtitleLabel = new JLabel("Complete breakdown by project"); + subtitleLabel.setFont(JBUI.Fonts.smallFont()); + subtitleLabel.setForeground(MUTED_TEXT); + subtitleLabel.setAlignmentX(LEFT_ALIGNMENT); + headerPanel.add(subtitleLabel); + + add(headerPanel, BorderLayout.NORTH); + + // CENTER: table container + tableContainer = new JPanel(); + tableContainer.setLayout(new BoxLayout(tableContainer, BoxLayout.Y_AXIS)); + tableContainer.setOpaque(false); + add(tableContainer, BorderLayout.CENTER); + + // SOUTH: pagination bar + JPanel paginationBar = new JPanel(new FlowLayout(FlowLayout.RIGHT, JBUI.scale(8), 0)); + paginationBar.setOpaque(false); + paginationBar.setBorder(JBUI.Borders.emptyTop(8)); + + JLabel rowsLabel = new JLabel("Rows per page"); + rowsLabel.setFont(JBUI.Fonts.smallFont()); + rowsLabel.setForeground(MUTED_TEXT); + paginationBar.add(rowsLabel); + + rowsPerPageCombo = new JComboBox<>(new Integer[] {5, 10, 25}); + rowsPerPageCombo.setSelectedItem(10); + rowsPerPageCombo.addActionListener( + e -> { + Integer selected = (Integer) rowsPerPageCombo.getSelectedItem(); + if (selected != null) { + rowsPerPage = selected; + currentPage = 0; + rebuildRows(); + } + }); + paginationBar.add(rowsPerPageCombo); + + paginationBar.add(Box.createHorizontalStrut(JBUI.scale(12))); + + pageInfoLabel = new JLabel(""); + pageInfoLabel.setFont(JBUI.Fonts.smallFont()); + pageInfoLabel.setForeground(MUTED_TEXT); + paginationBar.add(pageInfoLabel); + + paginationBar.add(Box.createHorizontalStrut(JBUI.scale(4))); + + prevButton = createNavButton("<"); + prevButton.addMouseListener( + new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + if (currentPage > 0) { + currentPage--; + rebuildRows(); + } + } + }); + paginationBar.add(prevButton); + + nextButton = createNavButton(">"); + nextButton.addMouseListener( + new java.awt.event.MouseAdapter() { + @Override + public void mouseClicked(java.awt.event.MouseEvent e) { + int totalPages = Math.max(1, (int) Math.ceil((double) allEntries.size() / rowsPerPage)); + if (currentPage < totalPages - 1) { + currentPage++; + rebuildRows(); + } + } + }); + paginationBar.add(nextButton); + + add(paginationBar, BorderLayout.SOUTH); + } + + public void update(List entries) { + this.allEntries = entries != null ? entries : new ArrayList<>(); + this.currentPage = 0; + rebuildRows(); + } + + private void rebuildRows() { + tableContainer.removeAll(); + + int total = allEntries.size(); + + if (total == 0) { + JLabel emptyLabel = new JLabel("No project data for the selected period"); + emptyLabel.setFont(JBUI.Fonts.label()); + emptyLabel.setForeground(MUTED_TEXT); + emptyLabel.setHorizontalAlignment(SwingConstants.CENTER); + emptyLabel.setAlignmentX(CENTER_ALIGNMENT); + emptyLabel.setBorder(JBUI.Borders.empty(20, 0)); + tableContainer.add(emptyLabel); + pageInfoLabel.setText("0 of 0"); + updateNavButtons(); + tableContainer.revalidate(); + tableContainer.repaint(); + return; + } + + // Column header row + tableContainer.add(createHeaderRow()); + + // Data rows for current page + int start = currentPage * rowsPerPage; + int end = Math.min(start + rowsPerPage, total); + for (int i = start; i < end; i++) { + tableContainer.add(createDataRow(allEntries.get(i), (i - start) % 2 == 1)); + } + + // Update pagination info + pageInfoLabel.setText((start + 1) + "–" + end + " of " + total); + updateNavButtons(); + + tableContainer.revalidate(); + tableContainer.repaint(); + } + + private JPanel createHeaderRow() { + JPanel row = new JPanel(new GridBagLayout()); + row.setOpaque(false); + row.setBorder(JBUI.Borders.emptyBottom(4)); + row.setMaximumSize( + new Dimension(Integer.MAX_VALUE, row.getPreferredSize().height + JBUI.scale(24))); + row.setAlignmentX(LEFT_ALIGNMENT); + + Font headerFont = JBUI.Fonts.smallFont().asBold(); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(0, JBUI.scale(4), 0, JBUI.scale(4)); + gbc.gridy = 0; + + // Project Name column - stretches + gbc.gridx = 0; + gbc.weightx = 1.0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.WEST; + JLabel projectHeader = new JLabel("Project Name"); + projectHeader.setFont(headerFont); + projectHeader.setForeground(MUTED_TEXT); + row.add(projectHeader, gbc); + + // Time Spent column + gbc.gridx = 1; + gbc.weightx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.EAST; + JLabel timeHeader = new JLabel("Time Spent"); + timeHeader.setFont(headerFont); + timeHeader.setForeground(MUTED_TEXT); + timeHeader.setPreferredSize( + new Dimension(JBUI.scale(110), timeHeader.getPreferredSize().height)); + timeHeader.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(timeHeader, gbc); + + // Lines Added column + gbc.gridx = 2; + JLabel addedHeader = new JLabel("Lines Added"); + addedHeader.setFont(headerFont); + addedHeader.setForeground(MUTED_TEXT); + addedHeader.setPreferredSize( + new Dimension(JBUI.scale(90), addedHeader.getPreferredSize().height)); + addedHeader.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(addedHeader, gbc); + + // Lines Removed column + gbc.gridx = 3; + JLabel removedHeader = new JLabel("Lines Removed"); + removedHeader.setFont(headerFont); + removedHeader.setForeground(MUTED_TEXT); + removedHeader.setPreferredSize( + new Dimension(JBUI.scale(100), removedHeader.getPreferredSize().height)); + removedHeader.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(removedHeader, gbc); + + return row; + } + + private JPanel createDataRow(ProjectBreakdownEntry entry, boolean alternate) { + JPanel row = new JPanel(new GridBagLayout()); + row.setAlignmentX(LEFT_ALIGNMENT); + if (alternate) { + row.setBackground(ALT_ROW_BG); + row.setOpaque(true); + } else { + row.setOpaque(false); + } + row.setBorder(JBUI.Borders.empty(4, 4)); + row.setMaximumSize( + new Dimension(Integer.MAX_VALUE, row.getPreferredSize().height + JBUI.scale(28))); + + Font dataFont = JBUI.Fonts.label(); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(0, JBUI.scale(4), 0, JBUI.scale(4)); + gbc.gridy = 0; + + // Project Name + gbc.gridx = 0; + gbc.weightx = 1.0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.WEST; + JLabel nameLabel = new JLabel(entry.projectName()); + nameLabel.setFont(dataFont); + row.add(nameLabel, gbc); + + // Time Spent + gbc.gridx = 1; + gbc.weightx = 0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.EAST; + JLabel timeLabel = new JLabel(formatTime(entry.timeSpentSeconds())); + timeLabel.setFont(dataFont); + timeLabel.setPreferredSize(new Dimension(JBUI.scale(110), timeLabel.getPreferredSize().height)); + timeLabel.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(timeLabel, gbc); + + // Lines Added + gbc.gridx = 2; + JLabel addedLabel = new JLabel("+" + entry.additions()); + addedLabel.setFont(dataFont); + addedLabel.setForeground(GREEN); + addedLabel.setPreferredSize( + new Dimension(JBUI.scale(90), addedLabel.getPreferredSize().height)); + addedLabel.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(addedLabel, gbc); + + // Lines Removed + gbc.gridx = 3; + JLabel removedLabel = new JLabel("-" + entry.removals()); + removedLabel.setFont(dataFont); + removedLabel.setForeground(RED); + removedLabel.setPreferredSize( + new Dimension(JBUI.scale(100), removedLabel.getPreferredSize().height)); + removedLabel.setHorizontalAlignment(SwingConstants.RIGHT); + row.add(removedLabel, gbc); + + return row; + } + + private void updateNavButtons() { + int totalPages = Math.max(1, (int) Math.ceil((double) allEntries.size() / rowsPerPage)); + prevButton.setEnabled(currentPage > 0); + prevButton.setForeground(currentPage > 0 ? getForeground() : MUTED_TEXT); + nextButton.setEnabled(currentPage < totalPages - 1); + nextButton.setForeground(currentPage < totalPages - 1 ? getForeground() : MUTED_TEXT); + } + + private JLabel createNavButton(String text) { + JLabel label = new JLabel(text); + label.setFont(JBUI.Fonts.label().asBold()); + label.setForeground(MUTED_TEXT); + label.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)); + label.setBorder(JBUI.Borders.empty(2, 6)); + return label; + } + + private static String formatTime(long seconds) { + if (seconds <= 0) { + return "0 s"; + } + long h = seconds / 3600; + long m = (seconds % 3600) / 60; + long s = seconds % 60; + if (h > 0) { + return h + " h " + m + " m " + s + " s"; + } + if (m > 0) { + return m + " m " + s + " s"; + } + return s + " s"; + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/JourneyBarPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/JourneyBarPanel.java new file mode 100644 index 0000000..5e67422 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/JourneyBarPanel.java @@ -0,0 +1,94 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.BorderLayout; +import java.awt.FlowLayout; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import javax.swing.JLabel; +import javax.swing.JPanel; +import org.jetbrains.annotations.Nullable; + +/** Horizontal bar showing lifetime CodeClocker stats. */ +public class JourneyBarPanel extends JPanel { + + private static final DateTimeFormatter DISPLAY_FORMAT = + DateTimeFormatter.ofPattern("MMM d, yyyy"); + + private final JLabel statsLabel; + private final JLabel trackingLabel; + + public JourneyBarPanel() { + setLayout(new BorderLayout(JBUI.scale(12), 0)); + setBorder( + JBUI.Borders.compound( + JBUI.Borders.customLine( + JBColor.namedColor("Borders.color", new JBColor(0xD0D0D0, 0x505050)), 1), + JBUI.Borders.empty(10, 14))); + + JLabel titleLabel = new JLabel("Your CodeClocker Journey"); + titleLabel.setFont(JBUI.Fonts.label().asBold()); + add(titleLabel, BorderLayout.WEST); + + statsLabel = new JLabel(); + statsLabel.setFont(JBUI.Fonts.smallFont()); + statsLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + JPanel centerPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 0)); + centerPanel.setOpaque(false); + centerPanel.add(statsLabel); + add(centerPanel, BorderLayout.CENTER); + + trackingLabel = new JLabel(); + trackingLabel.setFont(JBUI.Fonts.smallFont()); + trackingLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + add(trackingLabel, BorderLayout.EAST); + } + + public void update( + int days, long totalSeconds, int projects, long totalLines, @Nullable LocalDate firstDate) { + String timeStr = formatTime(totalSeconds); + String linesStr = formatLargeNumber(totalLines); + + statsLabel.setText( + days + + " days \u00B7 " + + timeStr + + " \u00B7 " + + projects + + " projects \u00B7 " + + linesStr + + " lines"); + + if (firstDate != null) { + long daysSinceFirst = ChronoUnit.DAYS.between(firstDate, LocalDate.now()); + trackingLabel.setText( + "Tracking since " + firstDate.format(DISPLAY_FORMAT) + " (" + daysSinceFirst + " days)"); + } else { + trackingLabel.setText(""); + } + } + + private String formatTime(long seconds) { + if (seconds <= 0) { + return "0m"; + } + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } + + private String formatLargeNumber(long value) { + if (value >= 1_000_000) { + return String.format("%.1fM", value / 1_000_000.0); + } + if (value >= 1_000) { + return String.format("%.1fk", value / 1_000.0); + } + return String.valueOf(value); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/MetricCardPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/MetricCardPanel.java new file mode 100644 index 0000000..6d5ed9c --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/MetricCardPanel.java @@ -0,0 +1,136 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.border.AbstractBorder; +import org.jetbrains.annotations.Nullable; + +/** A metric card panel mimicking the web UI's MetricCard widget. */ +public class MetricCardPanel extends JPanel { + + private final JLabel valueLabel; + private final JLabel subtitleLabel; + private final Color iconColor; + + public MetricCardPanel(String title, Color iconColor) { + this.iconColor = iconColor; + setLayout(new BorderLayout(0, JBUI.scale(4))); + setBorder( + JBUI.Borders.compound( + new RoundedBorder(JBColor.namedColor("Borders.color", new JBColor(0xD0D0D0, 0x505050))), + JBUI.Borders.empty(12, 14))); + setOpaque(false); + + // North: title + icon dot + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setOpaque(false); + JLabel titleLabel = new JLabel(title); + titleLabel.setFont(JBUI.Fonts.smallFont()); + titleLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + headerPanel.add(titleLabel, BorderLayout.WEST); + + JPanel dotPanel = + new JPanel() { + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(iconColor); + int size = JBUI.scale(10); + g2.fillOval(0, (getHeight() - size) / 2, size, size); + g2.dispose(); + } + + @Override + public Dimension getPreferredSize() { + int s = JBUI.scale(10); + return new Dimension(s, s); + } + }; + dotPanel.setOpaque(false); + headerPanel.add(dotPanel, BorderLayout.EAST); + add(headerPanel, BorderLayout.NORTH); + + // Center: value + valueLabel = new JLabel("\u2014", SwingConstants.CENTER); + valueLabel.setFont(JBUI.Fonts.label().biggerOn(6).asBold()); + add(valueLabel, BorderLayout.CENTER); + + // South: trend / subtitle + subtitleLabel = new JLabel("", SwingConstants.CENTER); + subtitleLabel.setFont(JBUI.Fonts.smallFont()); + subtitleLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + JPanel southPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 0, 0)); + southPanel.setOpaque(false); + southPanel.add(subtitleLabel); + add(southPanel, BorderLayout.SOUTH); + } + + public void update(String value, @Nullable Integer trendPercent, @Nullable String subtitle) { + valueLabel.setText(value); + + if (trendPercent != null) { + String arrow = trendPercent >= 0 ? "\u2197" : "\u2198"; + String text = String.format("%s %d%% vs prev", arrow, Math.abs(trendPercent)); + subtitleLabel.setText(text); + subtitleLabel.setForeground( + trendPercent >= 0 ? new JBColor(0x1B8A2D, 0x5EC46B) : new JBColor(0xC62828, 0xEF5350)); + } else if (subtitle != null) { + subtitleLabel.setText(subtitle); + subtitleLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + } else { + subtitleLabel.setText(""); + } + } + + public void setLoading(boolean loading) { + if (loading) { + valueLabel.setText("\u2014"); + subtitleLabel.setText(""); + } + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(getBackground()); + int arc = JBUI.scale(10); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + g2.dispose(); + } + + static class RoundedBorder extends AbstractBorder { + private final Color color; + + RoundedBorder(Color color) { + this.color = color; + } + + @Override + public void paintBorder(java.awt.Component c, Graphics g, int x, int y, int width, int height) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setColor(color); + int arc = JBUI.scale(10); + g2.drawRoundRect(x, y, width - 1, height - 1, arc, arc); + g2.dispose(); + } + + @Override + public java.awt.Insets getBorderInsets(java.awt.Component c) { + return JBUI.insets(1); + } + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/StreakCardPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/StreakCardPanel.java new file mode 100644 index 0000000..65c42d4 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/StreakCardPanel.java @@ -0,0 +1,115 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSeparator; +import javax.swing.SwingConstants; + +/** Streak display card with fire icon and warm gradient background. */ +public class StreakCardPanel extends JPanel { + + private static final String FIRE = "\uD83D\uDD25"; + private static final String TROPHY = "\uD83C\uDFC6"; + + private final JLabel streakValueLabel; + private final JLabel bestStreakLabel; + private boolean hasStreak; + + public StreakCardPanel() { + setLayout(new BorderLayout(0, JBUI.scale(4))); + setBorder( + JBUI.Borders.compound( + new MetricCardPanel.RoundedBorder( + JBColor.namedColor("Borders.color", new JBColor(0xD0D0D0, 0x505050))), + JBUI.Borders.empty(12, 14))); + setOpaque(false); + + // Main content + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setOpaque(false); + + // Top: fire icon + streak number + "days streak" + JPanel streakRow = new JPanel(); + streakRow.setLayout(new BoxLayout(streakRow, BoxLayout.X_AXIS)); + streakRow.setOpaque(false); + streakRow.setAlignmentX(CENTER_ALIGNMENT); + + JLabel fireLabel = new JLabel(FIRE); + fireLabel.setFont(JBUI.Fonts.label().biggerOn(4)); + streakRow.add(fireLabel); + streakRow.add(Box.createHorizontalStrut(JBUI.scale(4))); + + streakValueLabel = new JLabel("0"); + streakValueLabel.setFont(JBUI.Fonts.label().biggerOn(6).asBold()); + streakRow.add(streakValueLabel); + streakRow.add(Box.createHorizontalStrut(JBUI.scale(4))); + + JLabel daysLabel = new JLabel("days"); + daysLabel.setFont(JBUI.Fonts.smallFont()); + daysLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + streakRow.add(daysLabel); + + contentPanel.add(Box.createVerticalGlue()); + contentPanel.add(streakRow); + contentPanel.add(Box.createVerticalStrut(JBUI.scale(2))); + + JLabel streakLabel = new JLabel("streak"); + streakLabel.setFont(JBUI.Fonts.smallFont()); + streakLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + streakLabel.setAlignmentX(CENTER_ALIGNMENT); + contentPanel.add(streakLabel); + contentPanel.add(Box.createVerticalGlue()); + + add(contentPanel, BorderLayout.CENTER); + + // South: separator + best streak + JPanel southPanel = new JPanel(new BorderLayout(0, JBUI.scale(4))); + southPanel.setOpaque(false); + + JSeparator separator = new JSeparator(SwingConstants.HORIZONTAL); + southPanel.add(separator, BorderLayout.NORTH); + + bestStreakLabel = new JLabel(TROPHY + " Best: 0 days"); + bestStreakLabel.setFont(JBUI.Fonts.smallFont()); + bestStreakLabel.setForeground(new JBColor(0x5E6687, 0xA9B1D6)); + bestStreakLabel.setHorizontalAlignment(SwingConstants.CENTER); + southPanel.add(bestStreakLabel, BorderLayout.CENTER); + + add(southPanel, BorderLayout.SOUTH); + } + + public void update(int currentStreak, int longestStreak) { + this.hasStreak = currentStreak > 0; + streakValueLabel.setText(String.valueOf(currentStreak)); + bestStreakLabel.setText(TROPHY + " Best: " + longestStreak + " days"); + repaint(); + } + + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int arc = JBUI.scale(10); + + if (hasStreak) { + Color startColor = JBColor.namedColor("FileColor.Yellow", new JBColor(0xFFF8E1, 0x4E3B00)); + Color endColor = JBColor.namedColor("FileColor.Orange", new JBColor(0xFFF3E0, 0x4A2800)); + g2.setPaint(new GradientPaint(0, 0, startColor, 0, getHeight(), endColor)); + } else { + g2.setColor(getBackground()); + } + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + g2.dispose(); + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/TimePeriodSelectorPanel.java b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/TimePeriodSelectorPanel.java new file mode 100644 index 0000000..68fd9a6 --- /dev/null +++ b/src/main/java/com/codeclocker/plugin/intellij/dashboard/ui/TimePeriodSelectorPanel.java @@ -0,0 +1,102 @@ +package com.codeclocker.plugin.intellij.dashboard.ui; + +import com.codeclocker.plugin.intellij.dashboard.DashboardDataService.TimePeriod; +import com.intellij.ui.JBColor; +import com.intellij.util.ui.JBUI; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.FlowLayout; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.util.function.Consumer; +import javax.swing.JButton; +import javax.swing.JPanel; + +/** Chip-style period selector with styled buttons. */ +public class TimePeriodSelectorPanel extends JPanel { + + private static final Color SELECTED_BG = new JBColor(0x7C3AED, 0xA78BFA); + private static final Color SELECTED_FG = new JBColor(0xFFFFFF, 0x1E1E2E); + private static final Color UNSELECTED_BG = + JBColor.namedColor("ActionButton.hoverBackground", new JBColor(0xF0F0F0, 0x3C3F41)); + private static final Color UNSELECTED_FG = + JBColor.namedColor("Label.foreground", JBColor.foreground()); + + private TimePeriod selected = TimePeriod.LAST_7_DAYS; + private final JButton[] buttons; + + public TimePeriodSelectorPanel(Consumer onPeriodChanged) { + setLayout(new FlowLayout(FlowLayout.LEFT, JBUI.scale(6), JBUI.scale(4))); + setBorder(JBUI.Borders.empty(4, 8)); + + TimePeriod[] periods = TimePeriod.values(); + buttons = new JButton[periods.length]; + + for (int i = 0; i < periods.length; i++) { + TimePeriod period = periods[i]; + JButton button = createChipButton(period.getLabel()); + buttons[i] = button; + + button.addActionListener( + e -> { + selected = period; + updateButtonStyles(); + onPeriodChanged.accept(period); + }); + + add(button); + } + + updateButtonStyles(); + } + + public TimePeriod getSelected() { + return selected; + } + + private JButton createChipButton(String text) { + JButton button = + new JButton(text) { + @Override + protected void paintComponent(Graphics g) { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int arc = JBUI.scale(14); + + if (getModel().isPressed() || isCurrentlySelected()) { + g2.setColor(SELECTED_BG); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + g2.dispose(); + setForeground(SELECTED_FG); + } else { + g2.setColor(UNSELECTED_BG); + g2.fillRoundRect(0, 0, getWidth(), getHeight(), arc, arc); + g2.dispose(); + setForeground(UNSELECTED_FG); + } + + super.paintComponent(g); + } + + private boolean isCurrentlySelected() { + return getText().equals(selected.getLabel()); + } + }; + + button.setContentAreaFilled(false); + button.setBorderPainted(false); + button.setFocusPainted(false); + button.setOpaque(false); + button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + button.setFont(JBUI.Fonts.smallFont()); + button.setBorder(JBUI.Borders.empty(4, 12)); + return button; + } + + private void updateButtonStyles() { + for (JButton button : buttons) { + button.repaint(); + } + } +} diff --git a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java index 3c15a5f..4d44f89 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java +++ b/src/main/java/com/codeclocker/plugin/intellij/toolwindow/BranchActivityToolWindowFactory.java @@ -1,5 +1,6 @@ package com.codeclocker.plugin.intellij.toolwindow; +import com.codeclocker.plugin.intellij.dashboard.DashboardPanel; import com.intellij.openapi.project.Project; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowFactory; @@ -7,15 +8,22 @@ import com.intellij.ui.content.ContentFactory; import org.jetbrains.annotations.NotNull; -/** Factory for creating the Branch Activity tool window. */ +/** Factory for creating the Branch Activity tool window with Dashboard and Activity tabs. */ public class BranchActivityToolWindowFactory implements ToolWindowFactory { @Override public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { - BranchActivityPanel panel = new BranchActivityPanel(project); ContentFactory contentFactory = ContentFactory.getInstance(); - Content content = contentFactory.createContent(panel, "Activity", false); - toolWindow.getContentManager().addContent(content); + + // Dashboard tab (first position) + DashboardPanel dashboardPanel = new DashboardPanel(); + Content dashboardContent = contentFactory.createContent(dashboardPanel, "Dashboard", false); + toolWindow.getContentManager().addContent(dashboardContent); + + // Activity tab (existing) + BranchActivityPanel panel = new BranchActivityPanel(project); + Content activityContent = contentFactory.createContent(panel, "Activity", false); + toolWindow.getContentManager().addContent(activityContent); } @Override 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 e091895..670e053 100644 --- a/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java +++ b/src/main/java/com/codeclocker/plugin/intellij/widget/TimeTrackerPopup.java @@ -29,6 +29,8 @@ import com.intellij.openapi.ui.popup.util.BaseListPopupStep; import com.intellij.openapi.wm.ToolWindow; import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.ui.content.Content; +import com.intellij.ui.content.ContentManager; import java.util.ArrayList; import java.util.List; import org.jetbrains.annotations.Nullable; @@ -41,7 +43,8 @@ public class TimeTrackerPopup { private static final String SET_GOALS = "Set Goals..."; private static final String SET_PROJECT_GOALS = "Set Project Goals..."; private static final String AUTO_PAUSE = "Auto-Pause..."; - private static final String ACTIVITY_REPORT = "Activity Report..."; + private static final String DASHBOARD = "Dashboard..."; + private static final String ACTIVITY_REPORT = "Branch Activity..."; public static ListPopup create(Project project, String totalTime, String projectTime) { ChangesActivityTracker tracker = @@ -88,6 +91,7 @@ public static ListPopup create(Project project, String totalTime, String project items.add(SET_GOALS); items.add(SET_PROJECT_GOALS); items.add(AUTO_PAUSE); + items.add(DASHBOARD); items.add(ACTIVITY_REPORT); boolean hasApiKey = isNotBlank(ApiKeyPersistence.getApiKey()); @@ -109,6 +113,7 @@ public boolean isSelectable(String value) { || SET_GOALS.equals(value) || SET_PROJECT_GOALS.equals(value) || AUTO_PAUSE.equals(value) + || DASHBOARD.equals(value) || ACTIVITY_REPORT.equals(value); } @@ -132,14 +137,12 @@ public PopupStep onChosen(String selectedValue, boolean finalChoice) { } else if (AUTO_PAUSE.equals(selectedValue)) { Analytics.track(AnalyticsEventType.POPUP_AUTO_PAUSE_CLICK); TrackingSettingsDialog.showDialog(); + } else if (DASHBOARD.equals(selectedValue)) { + Analytics.track(AnalyticsEventType.POPUP_DASHBOARD_CLICK); + openToolWindowTab(project, "Dashboard"); } else if (ACTIVITY_REPORT.equals(selectedValue)) { Analytics.track(AnalyticsEventType.POPUP_ACTIVITY_REPORT_CLICK); - ToolWindow toolWindow = - ToolWindowManager.getInstance(project) - .getToolWindow("CodeClocker Activity Report"); - if (toolWindow != null) { - toolWindow.show(); - } + openToolWindowTab(project, "Activity"); } return FINAL_CHOICE; } @@ -161,6 +164,10 @@ public boolean hasSubstep(String selectedValue) { return new ListSeparator(); } + if (DASHBOARD.equals(value)) { + return new ListSeparator(); + } + // Goals section (first item is Daily goal) if (value.startsWith("Daily:") && value.contains("%")) { return new ListSeparator("Goals"); @@ -193,6 +200,21 @@ public boolean hasSubstep(String selectedValue) { return JBPopupFactory.getInstance().createListPopup(step); } + private static void openToolWindowTab(Project project, String tabName) { + ToolWindow toolWindow = + ToolWindowManager.getInstance(project).getToolWindow("CodeClocker Activity Report"); + if (toolWindow != null) { + toolWindow.show( + () -> { + ContentManager cm = toolWindow.getContentManager(); + Content content = cm.findContent(tabName); + if (content != null) { + cm.setSelectedContent(content); + } + }); + } + } + public static String getFormattedVcsChanges() { return String.format("+%d / -%d", GLOBAL_ADDITIONS.get(), GLOBAL_REMOVALS.get()); } @@ -268,7 +290,7 @@ private static String formatGoalProgress(String label, GoalProgress progress) { private static String formatProjectGoalProgress( String label, String projectName, GoalProgress progress) { // Use "P-" prefix to distinguish project goals in separator logic - String paddedLabel = label.equals("Daily") ? "P-Daily: " : "P-Weekly: "; + String paddedLabel = label.equals("Daily") ? "P-Daily: " : "P-Weekly: "; return String.format( "%s%s %s (%s)", paddedLabel, diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 6404c0d..d26382a 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -29,6 +29,7 @@ +