From c849757c46c819fb19310782a73a3cc39fe8b06d Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sat, 31 Jan 2026 23:03:11 -0500 Subject: [PATCH] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preview panel now supports back/forward history with arrow-key navigation ๐Ÿงญ - Added slick chevron buttons for preview back/forward with disabled states ๐Ÿ”™ - Wiki-link resolution now scans *all* markdown files, not just graphed ones ๐Ÿ—‚๏ธ - Graph data builder now pre-scans markdown paths for faster preview linking ๐Ÿ”Ž - Stats dashboards gained a new โ€œThis Quarterโ€ time range everywhere ๐Ÿ“† - Activity heatmap adds quarter support with richer 4-hour block granularity ๐Ÿงฑ - Brand-new Agent Efficiency chart compares average response time per agent โฑ๏ธ - New Weekday vs Weekend comparison chart highlights productivity patterns ๐Ÿ—“๏ธ - New โ€œTasks by Time of Dayโ€ chart surfaces Auto Run hourly hotspots ๐ŸŒ™ - Modal PR link is now keyboard-accessible and opens externally safely โ™ฟ --- .../DocumentGraph/DocumentGraphView.test.tsx | 135 ++++++++ src/main/stats-db.ts | 2 + .../DocumentGraph/DocumentGraphView.tsx | 147 +++++++- .../DocumentGraph/graphDataBuilder.ts | 13 + src/renderer/components/SymphonyModal.tsx | 18 +- .../UsageDashboard/ActivityHeatmap.tsx | 121 ++++--- .../UsageDashboard/AgentEfficiencyChart.tsx | 218 ++++++++++++ .../UsageDashboard/AgentUsageChart.tsx | 2 + .../UsageDashboard/DurationTrendsChart.tsx | 4 + .../UsageDashboard/SummaryCards.tsx | 88 ++++- .../UsageDashboard/TasksByHourChart.tsx | 302 ++++++++++++++++ .../UsageDashboard/UsageDashboardModal.tsx | 94 ++++- .../UsageDashboard/WeekdayComparisonChart.tsx | 321 ++++++++++++++++++ src/renderer/global.d.ts | 10 +- src/renderer/hooks/useStats.ts | 2 +- src/renderer/utils/documentStats.ts | 9 +- src/shared/stats-types.ts | 2 +- 17 files changed, 1386 insertions(+), 102 deletions(-) create mode 100644 src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx create mode 100644 src/renderer/components/UsageDashboard/TasksByHourChart.tsx create mode 100644 src/renderer/components/UsageDashboard/WeekdayComparisonChart.tsx diff --git a/src/__tests__/renderer/components/DocumentGraph/DocumentGraphView.test.tsx b/src/__tests__/renderer/components/DocumentGraph/DocumentGraphView.test.tsx index 1ac77327..272627fb 100644 --- a/src/__tests__/renderer/components/DocumentGraph/DocumentGraphView.test.tsx +++ b/src/__tests__/renderer/components/DocumentGraph/DocumentGraphView.test.tsx @@ -2989,5 +2989,140 @@ describe('DocumentGraphView', () => { expect(previewPriority).toBe(51); expect(previewPriority).toBeGreaterThan(MODAL_PRIORITIES.DOCUMENT_GRAPH); }); + + describe('Navigation History', () => { + /** + * The preview panel maintains a history stack for back/forward navigation. + * - Clicking wiki links pushes to history + * - Left arrow key navigates back + * - Right arrow key navigates forward + * - Visual chevron buttons are provided + */ + + it('maintains history stack when navigating via wiki links', () => { + // History is an array, index tracks current position + const previewHistory: Array<{ name: string }> = []; + let previewHistoryIndex = -1; + + // Simulate navigating to first document + previewHistory.push({ name: 'doc1.md' }); + previewHistoryIndex = 0; + + // Simulate navigating to second document via wiki link + previewHistory.push({ name: 'doc2.md' }); + previewHistoryIndex = 1; + + expect(previewHistory.length).toBe(2); + expect(previewHistoryIndex).toBe(1); + expect(previewHistory[previewHistoryIndex].name).toBe('doc2.md'); + }); + + it('left arrow navigates back in history', () => { + const previewHistory = [{ name: 'doc1.md' }, { name: 'doc2.md' }]; + let previewHistoryIndex = 1; + + // Simulate pressing left arrow + const canGoBack = previewHistoryIndex > 0; + if (canGoBack) { + previewHistoryIndex = previewHistoryIndex - 1; + } + + expect(previewHistoryIndex).toBe(0); + expect(previewHistory[previewHistoryIndex].name).toBe('doc1.md'); + }); + + it('right arrow navigates forward in history', () => { + const previewHistory = [{ name: 'doc1.md' }, { name: 'doc2.md' }]; + let previewHistoryIndex = 0; + + // Simulate pressing right arrow + const canGoForward = previewHistoryIndex < previewHistory.length - 1; + if (canGoForward) { + previewHistoryIndex = previewHistoryIndex + 1; + } + + expect(previewHistoryIndex).toBe(1); + expect(previewHistory[previewHistoryIndex].name).toBe('doc2.md'); + }); + + it('navigating to new document truncates forward history', () => { + let previewHistory = [{ name: 'doc1.md' }, { name: 'doc2.md' }, { name: 'doc3.md' }]; + let previewHistoryIndex = 0; // Currently viewing doc1 + + // Simulate navigating to new document from doc1 (should discard doc2, doc3) + const newEntry = { name: 'doc4.md' }; + previewHistory = previewHistory.slice(0, previewHistoryIndex + 1); + previewHistory.push(newEntry); + previewHistoryIndex = previewHistoryIndex + 1; + + expect(previewHistory.length).toBe(2); + expect(previewHistory[0].name).toBe('doc1.md'); + expect(previewHistory[1].name).toBe('doc4.md'); + }); + + it('cannot go back when at first document', () => { + const previewHistoryIndex = 0; + const canGoBack = previewHistoryIndex > 0; + + expect(canGoBack).toBe(false); + }); + + it('cannot go forward when at last document', () => { + const previewHistory = [{ name: 'doc1.md' }, { name: 'doc2.md' }]; + const previewHistoryIndex = previewHistory.length - 1; + const canGoForward = previewHistoryIndex < previewHistory.length - 1; + + expect(canGoForward).toBe(false); + }); + + it('closing preview clears history', () => { + let previewHistory: Array<{ name: string }> = [{ name: 'doc1.md' }, { name: 'doc2.md' }]; + let previewHistoryIndex = 1; + + // Simulate handlePreviewClose + previewHistory = []; + previewHistoryIndex = -1; + + expect(previewHistory.length).toBe(0); + expect(previewHistoryIndex).toBe(-1); + }); + + it('back button is disabled when canGoBack is false', () => { + const canGoBack = false; + const buttonStyle = { + opacity: canGoBack ? 1 : 0.4, + cursor: canGoBack ? 'pointer' : 'default', + }; + + expect(buttonStyle.opacity).toBe(0.4); + expect(buttonStyle.cursor).toBe('default'); + }); + + it('forward button is disabled when canGoForward is false', () => { + const canGoForward = false; + const buttonStyle = { + opacity: canGoForward ? 1 : 0.4, + cursor: canGoForward ? 'pointer' : 'default', + }; + + expect(buttonStyle.opacity).toBe(0.4); + expect(buttonStyle.cursor).toBe('default'); + }); + + it('keyboard handler only responds to unmodified arrow keys', () => { + // Should handle: ArrowLeft, ArrowRight without modifiers + // Should ignore: Cmd/Ctrl/Alt/Shift + arrows + const shouldHandle = (e: { key: string; metaKey?: boolean; ctrlKey?: boolean; altKey?: boolean; shiftKey?: boolean }) => { + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return false; + return e.key === 'ArrowLeft' || e.key === 'ArrowRight'; + }; + + expect(shouldHandle({ key: 'ArrowLeft' })).toBe(true); + expect(shouldHandle({ key: 'ArrowRight' })).toBe(true); + expect(shouldHandle({ key: 'ArrowLeft', metaKey: true })).toBe(false); + expect(shouldHandle({ key: 'ArrowRight', ctrlKey: true })).toBe(false); + expect(shouldHandle({ key: 'ArrowUp' })).toBe(false); + }); + }); }); }); diff --git a/src/main/stats-db.ts b/src/main/stats-db.ts index 47732202..5ecdce2d 100644 --- a/src/main/stats-db.ts +++ b/src/main/stats-db.ts @@ -175,6 +175,8 @@ function getTimeRangeStart(range: StatsTimeRange): number { return now - 7 * day; case 'month': return now - 30 * day; + case 'quarter': + return now - 90 * day; case 'year': return now - 365 * day; case 'all': diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 2f248a5d..50d62972 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -29,6 +29,8 @@ import { Calendar, CheckSquare, Type, + ChevronLeft, + ChevronRight, } from 'lucide-react'; import type { Theme } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; @@ -224,13 +226,20 @@ export function DocumentGraphView({ const [previewError, setPreviewError] = useState(null); const [previewLoading, setPreviewLoading] = useState(false); - // Build file tree from graph nodes for wiki-link resolution in preview + // Preview navigation history (for back/forward through wiki link clicks) + const [previewHistory, setPreviewHistory] = useState< + Array<{ path: string; relativePath: string; name: string; content: string }> + >([]); + const [previewHistoryIndex, setPreviewHistoryIndex] = useState(-1); + + // All markdown files discovered during scanning (for wiki-link resolution) + const [allMarkdownFiles, setAllMarkdownFiles] = useState([]); + + // Build file tree from ALL markdown files for wiki-link resolution in preview + // This enables linking to files that aren't currently loaded in the graph view const previewFileTree = useMemo(() => { - const filePaths = nodes - .filter((n) => n.nodeType === 'document' && n.filePath) - .map((n) => n.filePath!); - return buildFileTreeFromPaths(filePaths); - }, [nodes]); + return buildFileTreeFromPaths(allMarkdownFiles); + }, [allMarkdownFiles]); // Pagination state const [totalDocuments, setTotalDocuments] = useState(0); @@ -523,6 +532,9 @@ export function DocumentGraphView({ setLoadedDocuments(graphData.loadedDocuments); setHasMore(graphData.hasMore); + // Store all markdown files for wiki-link resolution in preview panel + setAllMarkdownFiles(graphData.allMarkdownFiles); + // Cache external data and link counts for instant toggling setCachedExternalData(graphData.cachedExternalData); setInternalLinkCount(graphData.internalLinkCount); @@ -977,6 +989,7 @@ export function DocumentGraphView({ /** * Open a markdown preview panel inside the graph view. + * Pushes to navigation history for back/forward support. */ const handlePreviewFile = useCallback( async (filePath: string) => { @@ -997,12 +1010,22 @@ export function DocumentGraphView({ throw new Error('Unable to read file contents.'); } - setPreviewFile({ + const newEntry = { path: fullPath, relativePath, name: relativePath.split('/').pop() || relativePath, content, + }; + + setPreviewFile(newEntry); + + // Push to history, truncating any forward history + setPreviewHistory((prev) => { + const newHistory = prev.slice(0, previewHistoryIndex + 1); + newHistory.push(newEntry); + return newHistory; }); + setPreviewHistoryIndex((prev) => prev + 1); } catch (err) { setPreviewFile(null); setPreviewError(err instanceof Error ? err.message : 'Failed to load preview.'); @@ -1010,18 +1033,65 @@ export function DocumentGraphView({ setPreviewLoading(false); } }, - [rootPath, sshRemoteId] + [rootPath, sshRemoteId, previewHistoryIndex] ); /** - * Close the preview panel + * Close the preview panel and clear navigation history */ const handlePreviewClose = useCallback(() => { setPreviewFile(null); setPreviewLoading(false); setPreviewError(null); + setPreviewHistory([]); + setPreviewHistoryIndex(-1); }, []); + /** + * Navigate back in preview history + */ + const handlePreviewBack = useCallback(() => { + if (previewHistoryIndex > 0) { + const newIndex = previewHistoryIndex - 1; + setPreviewHistoryIndex(newIndex); + setPreviewFile(previewHistory[newIndex]); + } + }, [previewHistoryIndex, previewHistory]); + + /** + * Navigate forward in preview history + */ + const handlePreviewForward = useCallback(() => { + if (previewHistoryIndex < previewHistory.length - 1) { + const newIndex = previewHistoryIndex + 1; + setPreviewHistoryIndex(newIndex); + setPreviewFile(previewHistory[newIndex]); + } + }, [previewHistoryIndex, previewHistory]); + + // Can navigate back/forward? + const canGoBack = previewHistoryIndex > 0; + const canGoForward = previewHistoryIndex < previewHistory.length - 1; + + /** + * Handle keyboard navigation in preview panel (left/right arrow keys) + */ + const handlePreviewKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Only handle arrow keys without modifiers + if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return; + + if (e.key === 'ArrowLeft' && canGoBack) { + e.preventDefault(); + handlePreviewBack(); + } else if (e.key === 'ArrowRight' && canGoForward) { + e.preventDefault(); + handlePreviewForward(); + } + }, + [canGoBack, canGoForward, handlePreviewBack, handlePreviewForward] + ); + /** * Register preview panel with layer stack when open. * Escape closes the preview and returns focus to the graph. @@ -1627,26 +1697,69 @@ export function DocumentGraphView({ {/* Markdown Preview Panel */} {(previewFile || previewLoading || previewError) && (
-
-

- {previewFile?.name || 'Loading preview...'} -

-

- {previewFile?.relativePath || ''} -

+
+ {/* Back/Forward navigation buttons */} +
+ + +
+ {/* Document title and path */} +
+

+ {previewFile?.name || 'Loading preview...'} +

+

+ {previewFile?.relativePath || ''} +

+
{previewFile && onDocumentOpen && ( diff --git a/src/renderer/components/DocumentGraph/graphDataBuilder.ts b/src/renderer/components/DocumentGraph/graphDataBuilder.ts index 620f8a82..b88e660d 100644 --- a/src/renderer/components/DocumentGraph/graphDataBuilder.ts +++ b/src/renderer/components/DocumentGraph/graphDataBuilder.ts @@ -243,6 +243,12 @@ export interface GraphData { internalLinkCount: number; /** Whether backlinks are still being loaded in the background */ backlinksLoading?: boolean; + /** + * All markdown file paths discovered during scanning (relative to rootPath). + * Used for wiki-link resolution in the preview panel - enables linking to + * files that aren't currently loaded in the graph view. + */ + allMarkdownFiles: string[]; /** * Start lazy loading of backlinks in the background. * Call this after the initial graph is displayed. @@ -609,6 +615,11 @@ export async function buildGraphData(options: BuildOptions): Promise sshRemoteId: !!sshRemoteId, }); + // Step 0: Scan all markdown files upfront (fast - just directory traversal, no content parsing) + // This enables wiki-link resolution in the preview panel for files not yet loaded in the graph + const allMarkdownFiles = await scanMarkdownFiles(rootPath, onProgress, sshRemoteId); + console.log(`[DocumentGraph] Found ${allMarkdownFiles.length} markdown files in ${rootPath}`); + // Track parsed files by path for deduplication const parsedFileMap = new Map(); // BFS queue: [relativePath, depth] @@ -633,6 +644,7 @@ export async function buildGraphData(options: BuildOptions): Promise totalLinkCount: 0, }, internalLinkCount: 0, + allMarkdownFiles, }; } @@ -989,6 +1001,7 @@ export async function buildGraphData(options: BuildOptions): Promise cachedExternalData, internalLinkCount, backlinksLoading: true, + allMarkdownFiles, startBacklinkScan, }; } diff --git a/src/renderer/components/SymphonyModal.tsx b/src/renderer/components/SymphonyModal.tsx index bbd85468..74bef1f8 100644 --- a/src/renderer/components/SymphonyModal.tsx +++ b/src/renderer/components/SymphonyModal.tsx @@ -319,23 +319,29 @@ function IssueCard({ {issue.documentPaths.length} {issue.documentPaths.length === 1 ? 'document' : 'documents'} {isClaimed && issue.claimedByPr && ( - { e.preventDefault(); e.stopPropagation(); window.maestro.shell.openExternal(issue.claimedByPr!.url); }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + window.maestro.shell.openExternal(issue.claimedByPr!.url); + } + }} > {issue.claimedByPr.isDraft ? 'Draft ' : ''}PR #{issue.claimedByPr.number} by @ {issue.claimedByPr.author} - + )}
diff --git a/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx b/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx index 23c3711b..2600bcac 100644 --- a/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx +++ b/src/renderer/components/UsageDashboard/ActivityHeatmap.tsx @@ -103,6 +103,8 @@ function getDaysForRange(timeRange: StatsTimeRange): number { return 7; case 'month': return 30; + case 'quarter': + return 90; case 'year': return 365; case 'all': @@ -122,10 +124,10 @@ function shouldUseSingleDayMode(timeRange: StatsTimeRange): boolean { /** * Check if we should use 4-hour block mode (6 blocks per day) - * Used for month view to show time-of-day patterns + * Used for month and quarter views to show time-of-day patterns with more granularity */ function shouldUse4HourBlockMode(timeRange: StatsTimeRange): boolean { - return timeRange === 'month'; + return timeRange === 'month' || timeRange === 'quarter'; } // Time block labels for 4-hour chunks @@ -636,18 +638,18 @@ export function ActivityHeatmap({
- {/* GitHub-style heatmap for month/year/all views */} + {/* GitHub-style heatmap for year/all views */} {useGitHubLayout && gitHubGrid && (
{/* Day of week labels (Y-axis) */} -
+
{dayOfWeekLabels.map((label, idx) => (
{/* Only show Mon, Wed, Fri for cleaner look */} @@ -659,14 +661,14 @@ export function ActivityHeatmap({ {/* Grid container */}
{/* Month labels row */} -
+
{gitHubGrid.monthLabels.map((monthLabel, idx) => (
{week.days.map((day) => (
)} - {/* 4-hour block heatmap for month view */} + {/* 4-hour block heatmap for month/quarter views */} {use4HourBlockLayout && blockGrid && (
{/* Time block labels (Y-axis) */} -
+
{TIME_BLOCK_LABELS.map((label, idx) => (
{label} @@ -743,50 +745,63 @@ export function ActivityHeatmap({ {/* Grid of cells with scrolling */}
-
- {blockGrid.dayColumns.map((col) => ( -
- {/* Day label */} +
+ {blockGrid.dayColumns.map((col, colIdx) => { + // Show day number for all days, but only show month on 1st of month + const isFirstOfMonth = col.date.getDate() === 1; + const showMonthLabel = isFirstOfMonth || colIdx === 0; + return (
- {col.dayLabel} -
- {/* Time block cells */} - {col.blocks.map((block) => ( + {/* Day label with month indicator */}
handleMouseEnterBlock(block, e)} - onMouseLeave={handleMouseLeave} - role="gridcell" - aria-label={`${format(block.date, 'MMM d')} ${block.blockLabel}: ${block.count} ${block.count === 1 ? 'query' : 'queries'}${block.duration > 0 ? `, ${formatDuration(block.duration)}` : ''}`} - tabIndex={0} - /> - ))} -
- ))} + title={format(col.date, 'EEEE, MMM d')} + > + {showMonthLabel && isFirstOfMonth + ? format(col.date, 'MMM') + : col.dayLabel} +
+ {/* Time block cells */} + {col.blocks.map((block) => ( +
handleMouseEnterBlock(block, e)} + onMouseLeave={handleMouseLeave} + role="gridcell" + aria-label={`${format(block.date, 'MMM d')} ${block.blockLabel}: ${block.count} ${block.count === 1 ? 'query' : 'queries'}${block.duration > 0 ? `, ${formatDuration(block.duration)}` : ''}`} + tabIndex={0} + /> + ))} +
+ ); + })}
diff --git a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx new file mode 100644 index 00000000..87d79882 --- /dev/null +++ b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx @@ -0,0 +1,218 @@ +/** + * AgentEfficiencyChart + * + * Displays efficiency metrics for each agent type using data from stats. + * Shows average duration per query for each agent, allowing comparison + * of which agents respond faster on average. + * + * Features: + * - Horizontal bar chart showing avg duration per query + * - Color-coded by agent + * - Sorted by efficiency (fastest first) + * - Colorblind-friendly palette option + */ + +import React, { useMemo } from 'react'; +import type { Theme } from '../../types'; +import type { StatsAggregation } from '../../hooks/useStats'; +import { COLORBLIND_AGENT_PALETTE } from '../../constants/colorblindPalettes'; + +interface AgentEfficiencyChartProps { + /** Aggregated stats data from the API */ + data: StatsAggregation; + /** Current theme for styling */ + theme: Theme; + /** Enable colorblind-friendly colors */ + colorBlindMode?: boolean; +} + +/** + * Format duration in milliseconds to human-readable string + */ +function formatDuration(ms: number): string { + if (ms === 0) return '0s'; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +/** + * Format agent type display name + */ +function formatAgentName(agent: string): string { + const names: Record = { + 'claude-code': 'Claude Code', + opencode: 'OpenCode', + 'openai-codex': 'OpenAI Codex', + codex: 'Codex', + 'gemini-cli': 'Gemini CLI', + 'qwen3-coder': 'Qwen3 Coder', + 'factory-droid': 'Factory Droid', + terminal: 'Terminal', + }; + return names[agent] || agent; +} + +/** + * Get color for an agent + */ +function getAgentColor(index: number, theme: Theme, colorBlindMode?: boolean): string { + if (colorBlindMode) { + return COLORBLIND_AGENT_PALETTE[index % COLORBLIND_AGENT_PALETTE.length]; + } + if (index === 0) { + return theme.colors.accent; + } + const additionalColors = [ + '#10b981', + '#8b5cf6', + '#ef4444', + '#06b6d4', + '#ec4899', + '#f59e0b', + '#84cc16', + '#6366f1', + ]; + return additionalColors[(index - 1) % additionalColors.length]; +} + +export function AgentEfficiencyChart({ + data, + theme, + colorBlindMode = false, +}: AgentEfficiencyChartProps) { + // Calculate efficiency data (avg duration per query) for each agent + const efficiencyData = useMemo(() => { + const agents = Object.entries(data.byAgent) + .map(([agent, stats]) => ({ + agent, + avgDuration: stats.count > 0 ? stats.duration / stats.count : 0, + totalQueries: stats.count, + totalDuration: stats.duration, + })) + .filter((a) => a.totalQueries > 0) // Only show agents with data + .sort((a, b) => a.avgDuration - b.avgDuration); // Fastest first + + return agents; + }, [data.byAgent]); + + // Get max duration for bar scaling + const maxDuration = useMemo(() => { + if (efficiencyData.length === 0) return 0; + return Math.max(...efficiencyData.map((a) => a.avgDuration)); + }, [efficiencyData]); + + if (efficiencyData.length === 0) { + return ( +
+

+ Agent Efficiency +

+
+ No agent query data available +
+
+ ); + } + + return ( +
+

+ Agent Efficiency + + (avg response time per query) + +

+ +
+ {efficiencyData.map((agent, index) => { + const percentage = maxDuration > 0 ? (agent.avgDuration / maxDuration) * 100 : 0; + const color = getAgentColor(index, theme, colorBlindMode); + + return ( +
+ {/* Agent name */} +
+
+ {formatAgentName(agent.agent)} +
+ + {/* Bar */} +
+
+ {percentage > 25 && ( + + {formatDuration(agent.avgDuration)} + + )} +
+
+ + {/* Duration label */} +
+ + {formatDuration(agent.avgDuration)} + + {agent.totalQueries} queries +
+
+ ); + })} +
+ + {/* Legend */} +
+ Sorted by efficiency (fastest first) +
+
+ ); +} + +export default AgentEfficiencyChart; diff --git a/src/renderer/components/UsageDashboard/AgentUsageChart.tsx b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx index f373184a..c9aa1f46 100644 --- a/src/renderer/components/UsageDashboard/AgentUsageChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentUsageChart.tsx @@ -114,6 +114,8 @@ function formatXAxisDate(dateStr: string, timeRange: StatsTimeRange): string { return format(date, 'EEE'); case 'month': return format(date, 'MMM d'); + case 'quarter': + return format(date, 'MMM d'); // Show month and day for quarter case 'year': return format(date, 'MMM'); case 'all': diff --git a/src/renderer/components/UsageDashboard/DurationTrendsChart.tsx b/src/renderer/components/UsageDashboard/DurationTrendsChart.tsx index 907da6ad..ed416531 100644 --- a/src/renderer/components/UsageDashboard/DurationTrendsChart.tsx +++ b/src/renderer/components/UsageDashboard/DurationTrendsChart.tsx @@ -108,6 +108,8 @@ function getWindowSize(timeRange: StatsTimeRange): number { return 3; case 'month': return 5; + case 'quarter': + return 7; // Weekly smoothing for quarter case 'year': return 7; case 'all': @@ -130,6 +132,8 @@ function formatXAxisDate(dateStr: string, timeRange: StatsTimeRange): string { return format(date, 'EEE'); case 'month': return format(date, 'MMM d'); + case 'quarter': + return format(date, 'MMM d'); // Show month and day for quarter case 'year': return format(date, 'MMM'); case 'all': diff --git a/src/renderer/components/UsageDashboard/SummaryCards.tsx b/src/renderer/components/UsageDashboard/SummaryCards.tsx index 77fe7420..09ff8fc6 100644 --- a/src/renderer/components/UsageDashboard/SummaryCards.tsx +++ b/src/renderer/components/UsageDashboard/SummaryCards.tsx @@ -18,7 +18,17 @@ */ import React, { useMemo } from 'react'; -import { MessageSquare, Clock, Timer, Bot, Users, Layers } from 'lucide-react'; +import { + MessageSquare, + Clock, + Timer, + Bot, + Users, + Layers, + Sunrise, + Globe, + Zap, +} from 'lucide-react'; import type { Theme } from '../../types'; import type { StatsAggregation } from '../../hooks/useStats'; @@ -114,23 +124,58 @@ function MetricCard({ icon, label, value, theme, animationIndex = 0 }: MetricCar ); } +/** + * Format hour number (0-23) to human-readable time + * Examples: 0 โ†’ "12 AM", 13 โ†’ "1 PM", 9 โ†’ "9 AM" + */ +function formatHour(hour: number): string { + const suffix = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + return `${displayHour} ${suffix}`; +} + export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) { // Calculate derived metrics - const { mostActiveAgent, interactiveRatio } = useMemo(() => { - // Find most active agent by query count - const agents = Object.entries(data.byAgent); - const topAgent = agents.length > 0 ? agents.sort((a, b) => b[1].count - a[1].count)[0] : null; + const { mostActiveAgent, interactiveRatio, peakHour, localVsRemote, queriesPerSession } = + useMemo(() => { + // Find most active agent by query count + const agents = Object.entries(data.byAgent); + const topAgent = + agents.length > 0 ? agents.sort((a, b) => b[1].count - a[1].count)[0] : null; - // Calculate interactive percentage - const totalBySource = data.bySource.user + data.bySource.auto; - const ratio = - totalBySource > 0 ? `${Math.round((data.bySource.user / totalBySource) * 100)}%` : 'N/A'; + // Calculate interactive percentage + const totalBySource = data.bySource.user + data.bySource.auto; + const ratio = + totalBySource > 0 ? `${Math.round((data.bySource.user / totalBySource) * 100)}%` : 'N/A'; - return { - mostActiveAgent: topAgent ? topAgent[0] : 'N/A', - interactiveRatio: ratio, - }; - }, [data.byAgent, data.bySource]); + // Find peak usage hour (hour with most queries) + const hourWithMostQueries = data.byHour.reduce( + (max, curr) => (curr.count > max.count ? curr : max), + { hour: 0, count: 0, duration: 0 } + ); + const peak = hourWithMostQueries.count > 0 ? formatHour(hourWithMostQueries.hour) : 'N/A'; + + // Calculate local vs remote percentage + const totalByLocation = data.byLocation.local + data.byLocation.remote; + const localPercent = + totalByLocation > 0 + ? `${Math.round((data.byLocation.local / totalByLocation) * 100)}%` + : 'N/A'; + + // Calculate queries per session + const qps = + data.totalSessions > 0 + ? (data.totalQueries / data.totalSessions).toFixed(1) + : 'N/A'; + + return { + mostActiveAgent: topAgent ? topAgent[0] : 'N/A', + interactiveRatio: ratio, + peakHour: peak, + localVsRemote: localPercent, + queriesPerSession: qps, + }; + }, [data.byAgent, data.bySource, data.byHour, data.byLocation, data.totalSessions, data.totalQueries]); const metrics = [ { @@ -143,6 +188,11 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) { label: 'Total Queries', value: formatNumber(data.totalQueries), }, + { + icon: , + label: 'Queries/Session', + value: queriesPerSession, + }, { icon: , label: 'Total Time', @@ -153,6 +203,11 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) { label: 'Avg Duration', value: formatDuration(data.avgDuration), }, + { + icon: , + label: 'Peak Hour', + value: peakHour, + }, { icon: , label: 'Top Agent', @@ -163,6 +218,11 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) { label: 'Interactive %', value: interactiveRatio, }, + { + icon: , + label: 'Local %', + value: localVsRemote, + }, ]; return ( diff --git a/src/renderer/components/UsageDashboard/TasksByHourChart.tsx b/src/renderer/components/UsageDashboard/TasksByHourChart.tsx new file mode 100644 index 00000000..addb3281 --- /dev/null +++ b/src/renderer/components/UsageDashboard/TasksByHourChart.tsx @@ -0,0 +1,302 @@ +/** + * TasksByHourChart + * + * Shows when Auto Run tasks are typically triggered throughout the day. + * Uses task startTime data to build an hourly distribution. + * + * Features: + * - 24-hour bar chart showing task distribution + * - Highlights peak hours + * - Shows success rate per hour + * - Theme-aware styling + */ + +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import type { Theme } from '../../types'; +import type { StatsTimeRange } from '../../hooks/useStats'; + +/** + * Auto Run task data shape from the API + */ +interface AutoRunTask { + id: string; + autoRunSessionId: string; + sessionId: string; + agentType: string; + taskIndex: number; + taskContent?: string; + startTime: number; + duration: number; + success: boolean; +} + +interface TasksByHourChartProps { + /** Current time range for filtering */ + timeRange: StatsTimeRange; + /** Current theme for styling */ + theme: Theme; +} + +/** + * Format hour number (0-23) to short format + */ +function formatHourShort(hour: number): string { + if (hour === 0) return '12a'; + if (hour === 12) return '12p'; + if (hour < 12) return `${hour}a`; + return `${hour - 12}p`; +} + +/** + * Format hour number (0-23) to full format + */ +function formatHourFull(hour: number): string { + const suffix = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour % 12 || 12; + return `${displayHour}:00 ${suffix}`; +} + +export function TasksByHourChart({ timeRange, theme }: TasksByHourChartProps) { + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hoveredHour, setHoveredHour] = useState(null); + + // Fetch Auto Run tasks + const fetchTasks = useCallback(async () => { + setLoading(true); + setError(null); + + try { + // Get all Auto Run sessions for the time range + const sessions = await window.maestro.stats.getAutoRunSessions(timeRange); + + // Fetch tasks for all sessions + const taskPromises = sessions.map((session) => + window.maestro.stats.getAutoRunTasks(session.id) + ); + const taskResults = await Promise.all(taskPromises); + setTasks(taskResults.flat()); + } catch (err) { + console.error('Failed to fetch Auto Run tasks:', err); + setError(err instanceof Error ? err.message : 'Failed to load tasks'); + } finally { + setLoading(false); + } + }, [timeRange]); + + useEffect(() => { + fetchTasks(); + + // Subscribe to stats updates + const unsubscribe = window.maestro.stats.onStatsUpdate(() => { + fetchTasks(); + }); + + return () => unsubscribe(); + }, [fetchTasks]); + + // Group tasks by hour + const hourlyData = useMemo(() => { + const hours: Array<{ hour: number; count: number; successCount: number }> = []; + + // Initialize all 24 hours + for (let i = 0; i < 24; i++) { + hours.push({ hour: i, count: 0, successCount: 0 }); + } + + // Count tasks per hour + tasks.forEach((task) => { + const hour = new Date(task.startTime).getHours(); + hours[hour].count++; + if (task.success) { + hours[hour].successCount++; + } + }); + + return hours; + }, [tasks]); + + // Find max count for scaling + const maxCount = useMemo(() => { + return Math.max(...hourlyData.map((h) => h.count), 1); + }, [hourlyData]); + + // Find peak hours (top 3) + const peakHours = useMemo(() => { + return [...hourlyData] + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .map((h) => h.hour); + }, [hourlyData]); + + // Total tasks + const totalTasks = useMemo(() => tasks.length, [tasks]); + + if (loading) { + return ( +
+

+ Tasks by Time of Day +

+
+ Loading... +
+
+ ); + } + + if (error) { + return ( +
+

+ Tasks by Time of Day +

+
+ Failed to load data + +
+
+ ); + } + + if (totalTasks === 0) { + return ( +
+

+ Tasks by Time of Day +

+
+ No Auto Run tasks in this time range +
+
+ ); + } + + const hoveredData = hoveredHour !== null ? hourlyData[hoveredHour] : null; + + return ( +
+

+ Tasks by Time of Day +

+ + {/* Chart */} +
+ {/* Tooltip */} + {hoveredData && ( +
+
{formatHourFull(hoveredHour!)}
+
+
{hoveredData.count} tasks
+ {hoveredData.count > 0 && ( +
+ {Math.round((hoveredData.successCount / hoveredData.count) * 100)}% success +
+ )} +
+
+ )} + + {/* Bars */} +
+ {hourlyData.map((hourData) => { + const height = maxCount > 0 ? (hourData.count / maxCount) * 100 : 0; + const isPeak = peakHours.includes(hourData.hour); + const isHovered = hoveredHour === hourData.hour; + + return ( +
setHoveredHour(hourData.hour)} + onMouseLeave={() => setHoveredHour(null)} + title={`${formatHourFull(hourData.hour)}: ${hourData.count} tasks`} + /> + ); + })} +
+ + {/* X-axis labels */} +
+ {formatHourShort(0)} + {formatHourShort(6)} + {formatHourShort(12)} + {formatHourShort(18)} + {formatHourShort(23)} +
+
+ + {/* Peak hours summary */} + {peakHours.length > 0 && hourlyData[peakHours[0]].count > 0 && ( +
+ Peak hours:{' '} + {peakHours + .filter((h) => hourlyData[h].count > 0) + .map((h) => ( + + {formatHourFull(h)} + + ))} +
+ )} +
+ ); +} + +export default TasksByHourChart; diff --git a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx index 5190a6fa..f89f6c7d 100644 --- a/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx +++ b/src/renderer/components/UsageDashboard/UsageDashboardModal.tsx @@ -25,6 +25,9 @@ import { DurationTrendsChart } from './DurationTrendsChart'; import { AgentUsageChart } from './AgentUsageChart'; import { AutoRunStats } from './AutoRunStats'; import { SessionStats } from './SessionStats'; +import { AgentEfficiencyChart } from './AgentEfficiencyChart'; +import { WeekdayComparisonChart } from './WeekdayComparisonChart'; +import { TasksByHourChart } from './TasksByHourChart'; import { EmptyState } from './EmptyState'; import { DashboardSkeleton } from './ChartSkeletons'; import { ChartErrorBoundary } from './ChartErrorBoundary'; @@ -44,9 +47,9 @@ const OVERVIEW_SECTIONS = [ 'activity-heatmap', 'duration-trends', ] as const; -const AGENTS_SECTIONS = ['session-stats', 'agent-comparison', 'agent-usage'] as const; -const ACTIVITY_SECTIONS = ['activity-heatmap', 'duration-trends'] as const; -const AUTORUN_SECTIONS = ['autorun-stats'] as const; +const AGENTS_SECTIONS = ['session-stats', 'agent-efficiency', 'agent-comparison', 'agent-usage'] as const; +const ACTIVITY_SECTIONS = ['activity-heatmap', 'weekday-comparison', 'duration-trends'] as const; +const AUTORUN_SECTIONS = ['autorun-stats', 'tasks-by-hour'] as const; type SectionId = | (typeof OVERVIEW_SECTIONS)[number] @@ -58,7 +61,7 @@ type SectionId = const perfMetrics = getRendererPerfMetrics('UsageDashboard'); // Stats time range type matching the backend API -type StatsTimeRange = 'day' | 'week' | 'month' | 'year' | 'all'; +type StatsTimeRange = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all'; // Aggregation data shape from the stats API interface StatsAggregation { @@ -118,6 +121,7 @@ const TIME_RANGE_OPTIONS: { value: StatsTimeRange; label: string }[] = [ { value: 'day', label: 'Today' }, { value: 'week', label: 'This Week' }, { value: 'month', label: 'This Month' }, + { value: 'quarter', label: 'This Quarter' }, { value: 'year', label: 'This Year' }, { value: 'all', label: 'All Time' }, ]; @@ -347,14 +351,17 @@ export function UsageDashboardModal({ const labels: Record = { 'summary-cards': 'Summary Cards', 'session-stats': 'Agent Statistics', + 'agent-efficiency': 'Agent Efficiency Chart', 'agent-comparison': 'Provider Comparison Chart', 'agent-usage': 'Agent Usage Chart', 'source-distribution': 'Session Type Chart', 'location-distribution': 'Location Distribution Chart', 'peak-hours': 'Peak Hours Chart', 'activity-heatmap': 'Activity Heatmap', + 'weekday-comparison': 'Weekday vs Weekend Chart', 'duration-trends': 'Duration Trends Chart', 'autorun-stats': 'Auto Run Statistics', + 'tasks-by-hour': 'Tasks by Time of Day Chart', }; return labels[sectionId] || sectionId; }, []); @@ -922,6 +929,33 @@ export function UsageDashboardModal({
+ {/* Agent Efficiency */} +
handleSectionKeyDown(e, 'agent-efficiency')} + className="outline-none rounded-lg transition-shadow dashboard-section-enter" + style={{ + minHeight: '180px', + boxShadow: + focusedSection === 'agent-efficiency' + ? `0 0 0 2px ${theme.colors.accent}` + : 'none', + animationDelay: '50ms', + }} + data-testid="section-agent-efficiency" + > + + + +
+ {/* Provider Comparison */}
+ + {/* Weekday vs Weekend Comparison */} +
handleSectionKeyDown(e, 'weekday-comparison')} + className="outline-none rounded-lg transition-shadow dashboard-section-enter" + style={{ + boxShadow: + focusedSection === 'weekday-comparison' + ? `0 0 0 2px ${theme.colors.accent}` + : 'none', + animationDelay: '50ms', + }} + data-testid="section-weekday-comparison" + > + + + +
+
+ + {/* Tasks by Time of Day */} +
handleSectionKeyDown(e, 'tasks-by-hour')} + className="outline-none rounded-lg transition-shadow dashboard-section-enter" + style={{ + boxShadow: + focusedSection === 'tasks-by-hour' + ? `0 0 0 2px ${theme.colors.accent}` + : 'none', + animationDelay: '100ms', + }} + data-testid="section-tasks-by-hour" + > + + + +
)}
diff --git a/src/renderer/components/UsageDashboard/WeekdayComparisonChart.tsx b/src/renderer/components/UsageDashboard/WeekdayComparisonChart.tsx new file mode 100644 index 00000000..7562d915 --- /dev/null +++ b/src/renderer/components/UsageDashboard/WeekdayComparisonChart.tsx @@ -0,0 +1,321 @@ +/** + * WeekdayComparisonChart + * + * Compares AI usage patterns between weekdays and weekends. + * Uses data from byDay to calculate aggregated metrics. + * + * Features: + * - Visual comparison of weekday vs weekend usage + * - Shows query counts and average duration for each + * - Calculates productivity ratio + * - Colorblind-friendly palette option + */ + +import React, { useMemo } from 'react'; +import { Briefcase, Coffee } from 'lucide-react'; +import type { Theme } from '../../types'; +import type { StatsAggregation } from '../../hooks/useStats'; + +interface WeekdayComparisonChartProps { + /** Aggregated stats data from the API */ + data: StatsAggregation; + /** Current theme for styling */ + theme: Theme; + /** Enable colorblind-friendly colors */ + colorBlindMode?: boolean; +} + +/** + * Format duration in milliseconds to human-readable string + */ +function formatDuration(ms: number): string { + if (ms === 0) return '0s'; + + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } + return `${seconds}s`; +} + +export function WeekdayComparisonChart({ + data, + theme, + colorBlindMode = false, +}: WeekdayComparisonChartProps) { + // Calculate weekday vs weekend statistics + const comparisonData = useMemo(() => { + const weekdayStats = { count: 0, duration: 0, days: 0 }; + const weekendStats = { count: 0, duration: 0, days: 0 }; + + data.byDay.forEach((day) => { + const date = new Date(day.date); + const dayOfWeek = date.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + if (isWeekend) { + weekendStats.count += day.count; + weekendStats.duration += day.duration; + weekendStats.days++; + } else { + weekdayStats.count += day.count; + weekdayStats.duration += day.duration; + weekdayStats.days++; + } + }); + + // Calculate averages + const weekdayAvgQueriesPerDay = + weekdayStats.days > 0 ? weekdayStats.count / weekdayStats.days : 0; + const weekendAvgQueriesPerDay = + weekendStats.days > 0 ? weekendStats.count / weekendStats.days : 0; + + const weekdayAvgDuration = + weekdayStats.count > 0 ? weekdayStats.duration / weekdayStats.count : 0; + const weekendAvgDuration = + weekendStats.count > 0 ? weekendStats.duration / weekendStats.count : 0; + + // Calculate which is more productive + const totalQueries = weekdayStats.count + weekendStats.count; + const weekdayPercentage = totalQueries > 0 ? (weekdayStats.count / totalQueries) * 100 : 0; + const weekendPercentage = totalQueries > 0 ? (weekendStats.count / totalQueries) * 100 : 0; + + return { + weekday: { + totalQueries: weekdayStats.count, + totalDuration: weekdayStats.duration, + avgQueriesPerDay: weekdayAvgQueriesPerDay, + avgDuration: weekdayAvgDuration, + days: weekdayStats.days, + percentage: weekdayPercentage, + }, + weekend: { + totalQueries: weekendStats.count, + totalDuration: weekendStats.duration, + avgQueriesPerDay: weekendAvgQueriesPerDay, + avgDuration: weekendAvgDuration, + days: weekendStats.days, + percentage: weekendPercentage, + }, + totalQueries, + }; + }, [data.byDay]); + + const hasData = comparisonData.totalQueries > 0; + + // Colors for weekday/weekend + const weekdayColor = colorBlindMode ? '#0077BB' : theme.colors.accent; + const weekendColor = colorBlindMode ? '#EE7733' : '#8b5cf6'; + + if (!hasData) { + return ( +
+

+ Weekday vs Weekend +

+
+ No daily data available +
+
+ ); + } + + const maxPercentage = Math.max( + comparisonData.weekday.percentage, + comparisonData.weekend.percentage + ); + + return ( +
+

+ Weekday vs Weekend +

+ +
+ {/* Weekday Card */} +
+
+
+ +
+
+
+ Weekdays +
+
+ Mon - Fri +
+
+
+ + {/* Bar */} +
+
+
+ +
+
+ Total Queries + + {comparisonData.weekday.totalQueries.toLocaleString()} + +
+
+ Avg/Day + + {comparisonData.weekday.avgQueriesPerDay.toFixed(1)} + +
+
+ Avg Duration + + {formatDuration(comparisonData.weekday.avgDuration)} + +
+
+ Share + + {comparisonData.weekday.percentage.toFixed(1)}% + +
+
+
+ + {/* Weekend Card */} +
+
+
+ +
+
+
+ Weekends +
+
+ Sat - Sun +
+
+
+ + {/* Bar */} +
+
+
+ +
+
+ Total Queries + + {comparisonData.weekend.totalQueries.toLocaleString()} + +
+
+ Avg/Day + + {comparisonData.weekend.avgQueriesPerDay.toFixed(1)} + +
+
+ Avg Duration + + {formatDuration(comparisonData.weekend.avgDuration)} + +
+
+ Share + + {comparisonData.weekend.percentage.toFixed(1)}% + +
+
+
+
+ + {/* Insight */} + {comparisonData.weekday.days > 0 && comparisonData.weekend.days > 0 && ( +
+ {comparisonData.weekday.avgQueriesPerDay > comparisonData.weekend.avgQueriesPerDay ? ( + + You're{' '} + + {( + (comparisonData.weekday.avgQueriesPerDay / + comparisonData.weekend.avgQueriesPerDay) * + 100 - + 100 + ).toFixed(0)} + % + {' '} + more active on weekdays + + ) : comparisonData.weekend.avgQueriesPerDay > 0 ? ( + + You're{' '} + + {( + (comparisonData.weekend.avgQueriesPerDay / + comparisonData.weekday.avgQueriesPerDay) * + 100 - + 100 + ).toFixed(0)} + % + {' '} + more active on weekends + + ) : ( + Similar activity on weekdays and weekends + )} +
+ )} +
+ ); +} + +export default WeekdayComparisonChart; diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index af0d4aae..ef020d28 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -2132,7 +2132,7 @@ interface MaestroAPI { }) => Promise; // Get query events with time range and optional filters getStats: ( - range: 'day' | 'week' | 'month' | 'year' | 'all', + range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all', filters?: { agentType?: string; source?: 'user' | 'auto'; @@ -2152,7 +2152,7 @@ interface MaestroAPI { }> >; // Get Auto Run sessions within a time range - getAutoRunSessions: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise< + getAutoRunSessions: (range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all') => Promise< Array<{ id: string; sessionId: string; @@ -2180,7 +2180,7 @@ interface MaestroAPI { }> >; // Get aggregated stats for dashboard display - getAggregation: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<{ + getAggregation: (range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all') => Promise<{ totalQueries: number; totalDuration: number; avgDuration: number; @@ -2197,7 +2197,7 @@ interface MaestroAPI { bySessionByDay: Record>; }>; // Export query events to CSV - exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise; + exportCsv: (range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all') => Promise; // Subscribe to stats updates (for real-time dashboard refresh) onStatsUpdate: (callback: () => void) => () => void; // Clear old stats data (older than specified number of days) @@ -2224,7 +2224,7 @@ interface MaestroAPI { // Record session closure recordSessionClosed: (sessionId: string, closedAt: number) => Promise; // Get session lifecycle events within a time range - getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise< + getSessionLifecycle: (range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all') => Promise< Array<{ id: string; sessionId: string; diff --git a/src/renderer/hooks/useStats.ts b/src/renderer/hooks/useStats.ts index cd2fa373..43b87a36 100644 --- a/src/renderer/hooks/useStats.ts +++ b/src/renderer/hooks/useStats.ts @@ -17,7 +17,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; // Stats time range type matching the backend API -export type StatsTimeRange = 'day' | 'week' | 'month' | 'year' | 'all'; +export type StatsTimeRange = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all'; // Aggregation data shape from the stats API export interface StatsAggregation { diff --git a/src/renderer/utils/documentStats.ts b/src/renderer/utils/documentStats.ts index 2de57a42..5f0155b8 100644 --- a/src/renderer/utils/documentStats.ts +++ b/src/renderer/utils/documentStats.ts @@ -209,7 +209,7 @@ const MAX_CONTENT_PREVIEW_LENGTH = 600; /** * Strip markdown syntax from content and return plaintext. - * Removes: headings, bold/italic, links, images, code blocks, blockquotes, lists, etc. + * Removes: headings, bold/italic, links, images, code blocks, blockquotes, lists, tables, etc. * * @param content - Markdown content * @returns Plaintext version of the content @@ -226,6 +226,13 @@ function stripMarkdownSyntax(content: string): string { // Remove inline code (`code`) text = text.replace(/`[^`]+`/g, ''); + // Remove markdown tables entirely (header row, separator row, and data rows) + // Match lines that start with optional whitespace and a pipe character + // Tables typically have: | col1 | col2 | followed by |---|---| separator + text = text.replace(/^\s*\|.+\|\s*$/gm, ''); + // Also remove table separator lines like |---|---| + text = text.replace(/^\s*\|[-:\s|]+\|\s*$/gm, ''); + // Remove images ![alt](url) text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, ''); diff --git a/src/shared/stats-types.ts b/src/shared/stats-types.ts index fe33d4ac..86f0b716 100644 --- a/src/shared/stats-types.ts +++ b/src/shared/stats-types.ts @@ -69,7 +69,7 @@ export interface SessionLifecycleEvent { /** * Time range for querying stats */ -export type StatsTimeRange = 'day' | 'week' | 'month' | 'year' | 'all'; +export type StatsTimeRange = 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all'; /** * Aggregated stats for dashboard display