## CHANGES

- 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 
This commit is contained in:
Pedram Amini
2026-01-31 23:03:11 -05:00
parent ec3a5b528f
commit c849757c46
17 changed files with 1386 additions and 102 deletions

View File

@@ -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);
});
});
});
});

View File

@@ -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':

View File

@@ -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<string | null>(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<string[]>([]);
// 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) && (
<div
className="absolute top-4 right-4 bottom-4 rounded-lg shadow-2xl border flex flex-col z-50"
className="absolute top-4 right-4 bottom-4 rounded-lg shadow-2xl border flex flex-col z-50 outline-none"
style={{
backgroundColor: theme.colors.bgActivity,
borderColor: theme.colors.border,
width: 'min(560px, 42vw)',
maxWidth: '90%',
}}
onKeyDown={handlePreviewKeyDown}
>
<style>{generateProseStyles({ theme, scopeSelector: '.graph-preview' })}</style>
<div
className="px-4 py-3 border-b flex items-center justify-between gap-3"
style={{ borderColor: theme.colors.border }}
>
<div className="min-w-0">
<p className="text-sm font-semibold truncate" style={{ color: theme.colors.textMain }}>
{previewFile?.name || 'Loading preview...'}
</p>
<p className="text-xs truncate" style={{ color: theme.colors.textDim }}>
{previewFile?.relativePath || ''}
</p>
<div className="flex items-center gap-2 min-w-0">
{/* Back/Forward navigation buttons */}
<div className="flex items-center gap-0.5">
<button
onClick={handlePreviewBack}
disabled={!canGoBack}
className="p-1 rounded transition-colors"
style={{
color: canGoBack ? theme.colors.textMain : theme.colors.textDim,
opacity: canGoBack ? 1 : 0.4,
cursor: canGoBack ? 'pointer' : 'default',
}}
onMouseEnter={(e) =>
canGoBack && (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
title={canGoBack ? 'Go back ()' : 'No previous document'}
aria-label="Go back"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={handlePreviewForward}
disabled={!canGoForward}
className="p-1 rounded transition-colors"
style={{
color: canGoForward ? theme.colors.textMain : theme.colors.textDim,
opacity: canGoForward ? 1 : 0.4,
cursor: canGoForward ? 'pointer' : 'default',
}}
onMouseEnter={(e) =>
canGoForward && (e.currentTarget.style.backgroundColor = `${theme.colors.accent}20`)
}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
title={canGoForward ? 'Go forward ()' : 'No next document'}
aria-label="Go forward"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Document title and path */}
<div className="min-w-0">
<p className="text-sm font-semibold truncate" style={{ color: theme.colors.textMain }}>
{previewFile?.name || 'Loading preview...'}
</p>
<p className="text-xs truncate" style={{ color: theme.colors.textDim }}>
{previewFile?.relativePath || ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{previewFile && onDocumentOpen && (

View File

@@ -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<GraphData>
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<string, ParsedFile>();
// BFS queue: [relativePath, depth]
@@ -633,6 +644,7 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
totalLinkCount: 0,
},
internalLinkCount: 0,
allMarkdownFiles,
};
}
@@ -989,6 +1001,7 @@ export async function buildGraphData(options: BuildOptions): Promise<GraphData>
cachedExternalData,
internalLinkCount,
backlinksLoading: true,
allMarkdownFiles,
startBacklinkScan,
};
}

View File

@@ -319,23 +319,29 @@ function IssueCard({
{issue.documentPaths.length} {issue.documentPaths.length === 1 ? 'document' : 'documents'}
</span>
{isClaimed && issue.claimedByPr && (
<a
href={issue.claimedByPr.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 cursor-pointer hover:underline"
<span
role="link"
tabIndex={0}
className="flex items-center gap-1 cursor-pointer hover:underline pointer-events-auto"
style={{ color: theme.colors.accent }}
onClick={(e) => {
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);
}
}}
>
<GitPullRequest className="w-3 h-3" />
{issue.claimedByPr.isDraft ? 'Draft ' : ''}PR #{issue.claimedByPr.number} by @
{issue.claimedByPr.author}
<ExternalLink className="w-2.5 h-2.5" />
</a>
</span>
)}
</div>

View File

@@ -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({
</div>
</div>
{/* GitHub-style heatmap for month/year/all views */}
{/* GitHub-style heatmap for year/all views */}
{useGitHubLayout && gitHubGrid && (
<div className="flex gap-2">
{/* Day of week labels (Y-axis) */}
<div className="flex flex-col flex-shrink-0" style={{ width: 32, paddingTop: 20 }}>
<div className="flex flex-col flex-shrink-0" style={{ width: 36, paddingTop: 22 }}>
{dayOfWeekLabels.map((label, idx) => (
<div
key={idx}
className="text-xs text-right flex items-center justify-end pr-1"
style={{
color: theme.colors.textDim,
height: 13,
height: 15,
}}
>
{/* Only show Mon, Wed, Fri for cleaner look */}
@@ -659,14 +661,14 @@ export function ActivityHeatmap({
{/* Grid container */}
<div className="flex-1 overflow-x-auto">
{/* Month labels row */}
<div className="flex" style={{ marginBottom: 4, height: 16 }}>
<div className="flex" style={{ marginBottom: 6, height: 18 }}>
{gitHubGrid.monthLabels.map((monthLabel, idx) => (
<div
key={`${monthLabel.month}-${idx}`}
className="text-xs"
style={{
color: theme.colors.textDim,
width: monthLabel.colSpan * 13, // 11px cell + 2px gap
width: monthLabel.colSpan * 15, // 13px cell + 2px gap
paddingLeft: 2,
flexShrink: 0,
}}
@@ -682,15 +684,15 @@ export function ActivityHeatmap({
<div
key={weekIdx}
className="flex flex-col gap-[2px]"
style={{ width: 11, flexShrink: 0 }}
style={{ width: 13, flexShrink: 0 }}
>
{week.days.map((day) => (
<div
key={day.dateString}
className="rounded-sm cursor-default"
style={{
width: 11,
height: 11,
width: 13,
height: 13,
backgroundColor: day.isPlaceholder
? 'transparent'
: getIntensityColor(day.intensity, theme, colorBlindMode),
@@ -722,18 +724,18 @@ export function ActivityHeatmap({
</div>
)}
{/* 4-hour block heatmap for month view */}
{/* 4-hour block heatmap for month/quarter views */}
{use4HourBlockLayout && blockGrid && (
<div className="flex gap-2">
{/* Time block labels (Y-axis) */}
<div className="flex flex-col flex-shrink-0" style={{ width: 48, paddingTop: 18 }}>
<div className="flex flex-col flex-shrink-0" style={{ width: 52, paddingTop: 22 }}>
{TIME_BLOCK_LABELS.map((label, idx) => (
<div
key={idx}
className="text-xs text-right flex items-center justify-end pr-1"
className="text-xs text-right flex items-center justify-end pr-2"
style={{
color: theme.colors.textDim,
height: 18,
height: 20,
}}
>
{label}
@@ -743,50 +745,63 @@ export function ActivityHeatmap({
{/* Grid of cells with scrolling */}
<div className="flex-1 overflow-x-auto">
<div className="flex gap-[2px]" style={{ minWidth: blockGrid.dayColumns.length * 15 }}>
{blockGrid.dayColumns.map((col) => (
<div
key={col.dateString}
className="flex flex-col gap-[2px]"
style={{ width: 13, flexShrink: 0 }}
>
{/* Day label */}
<div className="flex gap-[3px]" style={{ minWidth: blockGrid.dayColumns.length * 17 }}>
{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 (
<div
className="text-xs text-center truncate h-[16px] flex items-center justify-center"
style={{ color: theme.colors.textDim, fontSize: 10 }}
title={format(col.date, 'EEEE, MMM d')}
key={col.dateString}
className="flex flex-col gap-[3px]"
style={{ width: 14, flexShrink: 0 }}
>
{col.dayLabel}
</div>
{/* Time block cells */}
{col.blocks.map((block) => (
{/* Day label with month indicator */}
<div
key={`${col.dateString}-${block.blockIndex}`}
className="rounded-sm cursor-default"
className="text-xs text-center truncate h-[18px] flex items-center justify-center"
style={{
height: 16,
backgroundColor: block.isPlaceholder
? 'transparent'
: getIntensityColor(block.intensity, theme, colorBlindMode),
outline:
hoveredCell &&
'blockIndex' in hoveredCell &&
hoveredCell.dateString === block.dateString &&
hoveredCell.blockIndex === block.blockIndex
? `2px solid ${theme.colors.accent}`
: 'none',
outlineOffset: -1,
transition: 'background-color 0.3s ease, outline 0.15s ease',
color: isFirstOfMonth
? theme.colors.accent
: theme.colors.textDim,
fontSize: 10,
fontWeight: isFirstOfMonth ? 600 : 400,
}}
onMouseEnter={(e) => 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}
/>
))}
</div>
))}
title={format(col.date, 'EEEE, MMM d')}
>
{showMonthLabel && isFirstOfMonth
? format(col.date, 'MMM')
: col.dayLabel}
</div>
{/* Time block cells */}
{col.blocks.map((block) => (
<div
key={`${col.dateString}-${block.blockIndex}`}
className="rounded-sm cursor-default"
style={{
height: 17,
backgroundColor: block.isPlaceholder
? 'transparent'
: getIntensityColor(block.intensity, theme, colorBlindMode),
outline:
hoveredCell &&
'blockIndex' in hoveredCell &&
hoveredCell.dateString === block.dateString &&
hoveredCell.blockIndex === block.blockIndex
? `2px solid ${theme.colors.accent}`
: 'none',
outlineOffset: -1,
transition: 'background-color 0.3s ease, outline 0.15s ease',
}}
onMouseEnter={(e) => 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}
/>
))}
</div>
);
})}
</div>
</div>
</div>

View File

@@ -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<string, string> = {
'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 (
<div className="p-4 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Agent Efficiency
</h3>
<div
className="flex items-center justify-center h-24"
style={{ color: theme.colors.textDim }}
>
<span className="text-sm">No agent query data available</span>
</div>
</div>
);
}
return (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
data-testid="agent-efficiency-chart"
>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Agent Efficiency
<span
className="text-xs font-normal ml-2"
style={{ color: theme.colors.textDim }}
>
(avg response time per query)
</span>
</h3>
<div className="space-y-3">
{efficiencyData.map((agent, index) => {
const percentage = maxDuration > 0 ? (agent.avgDuration / maxDuration) * 100 : 0;
const color = getAgentColor(index, theme, colorBlindMode);
return (
<div key={agent.agent} className="flex items-center gap-3">
{/* Agent name */}
<div
className="w-28 text-sm truncate flex-shrink-0 flex items-center gap-2"
style={{ color: theme.colors.textDim }}
title={formatAgentName(agent.agent)}
>
<div
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: color }}
/>
{formatAgentName(agent.agent)}
</div>
{/* Bar */}
<div
className="flex-1 h-6 rounded overflow-hidden"
style={{ backgroundColor: `${theme.colors.border}30` }}
>
<div
className="h-full rounded flex items-center justify-end"
style={{
width: `${Math.max(percentage, 8)}%`,
backgroundColor: color,
opacity: 0.85,
transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{percentage > 25 && (
<span
className="text-xs font-medium px-2 text-white whitespace-nowrap"
style={{ textShadow: '0 1px 2px rgba(0,0,0,0.3)' }}
>
{formatDuration(agent.avgDuration)}
</span>
)}
</div>
</div>
{/* Duration label */}
<div
className="w-20 text-xs text-right flex-shrink-0 flex flex-col"
style={{ color: theme.colors.textDim }}
>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{formatDuration(agent.avgDuration)}
</span>
<span className="text-[10px] opacity-70">{agent.totalQueries} queries</span>
</div>
</div>
);
})}
</div>
{/* Legend */}
<div
className="mt-4 pt-3 border-t text-xs flex items-center gap-4"
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
>
<span>Sorted by efficiency (fastest first)</span>
</div>
</div>
);
}
export default AgentEfficiencyChart;

View File

@@ -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':

View File

@@ -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':

View File

@@ -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: <Zap className="w-4 h-4" />,
label: 'Queries/Session',
value: queriesPerSession,
},
{
icon: <Clock className="w-4 h-4" />,
label: 'Total Time',
@@ -153,6 +203,11 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
label: 'Avg Duration',
value: formatDuration(data.avgDuration),
},
{
icon: <Sunrise className="w-4 h-4" />,
label: 'Peak Hour',
value: peakHour,
},
{
icon: <Bot className="w-4 h-4" />,
label: 'Top Agent',
@@ -163,6 +218,11 @@ export function SummaryCards({ data, theme, columns = 3 }: SummaryCardsProps) {
label: 'Interactive %',
value: interactiveRatio,
},
{
icon: <Globe className="w-4 h-4" />,
label: 'Local %',
value: localVsRemote,
},
];
return (

View File

@@ -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<AutoRunTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hoveredHour, setHoveredHour] = useState<number | null>(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 (
<div className="p-4 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Tasks by Time of Day
</h3>
<div
className="h-32 flex items-center justify-center"
style={{ color: theme.colors.textDim }}
>
<span className="text-sm">Loading...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="p-4 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Tasks by Time of Day
</h3>
<div
className="h-32 flex flex-col items-center justify-center gap-2"
style={{ color: theme.colors.textDim }}
>
<span className="text-sm">Failed to load data</span>
<button
onClick={fetchTasks}
className="px-3 py-1 rounded text-sm"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.bgMain,
}}
>
Retry
</button>
</div>
</div>
);
}
if (totalTasks === 0) {
return (
<div className="p-4 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Tasks by Time of Day
</h3>
<div
className="h-32 flex items-center justify-center"
style={{ color: theme.colors.textDim }}
>
<span className="text-sm">No Auto Run tasks in this time range</span>
</div>
</div>
);
}
const hoveredData = hoveredHour !== null ? hourlyData[hoveredHour] : null;
return (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
data-testid="tasks-by-hour-chart"
>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Tasks by Time of Day
</h3>
{/* Chart */}
<div className="relative">
{/* Tooltip */}
{hoveredData && (
<div
className="absolute z-10 px-3 py-2 rounded text-xs whitespace-nowrap pointer-events-none shadow-lg"
style={{
left: `${(hoveredHour! / 24) * 100}%`,
bottom: '100%',
transform: 'translateX(-50%)',
marginBottom: '8px',
backgroundColor: theme.colors.bgActivity,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`,
}}
>
<div className="font-medium mb-1">{formatHourFull(hoveredHour!)}</div>
<div style={{ color: theme.colors.textDim }}>
<div>{hoveredData.count} tasks</div>
{hoveredData.count > 0 && (
<div>
{Math.round((hoveredData.successCount / hoveredData.count) * 100)}% success
</div>
)}
</div>
</div>
)}
{/* Bars */}
<div
className="flex items-end gap-0.5 h-24"
role="img"
aria-label="Tasks by hour of day"
>
{hourlyData.map((hourData) => {
const height = maxCount > 0 ? (hourData.count / maxCount) * 100 : 0;
const isPeak = peakHours.includes(hourData.hour);
const isHovered = hoveredHour === hourData.hour;
return (
<div
key={hourData.hour}
className="flex-1 rounded-t cursor-pointer transition-all duration-150"
style={{
height: `${Math.max(height, 2)}%`,
backgroundColor: isPeak ? theme.colors.accent : theme.colors.border,
opacity: isHovered ? 1 : isPeak ? 0.9 : 0.5,
}}
onMouseEnter={() => setHoveredHour(hourData.hour)}
onMouseLeave={() => setHoveredHour(null)}
title={`${formatHourFull(hourData.hour)}: ${hourData.count} tasks`}
/>
);
})}
</div>
{/* X-axis labels */}
<div
className="flex justify-between mt-2 text-[10px]"
style={{ color: theme.colors.textDim }}
>
<span>{formatHourShort(0)}</span>
<span>{formatHourShort(6)}</span>
<span>{formatHourShort(12)}</span>
<span>{formatHourShort(18)}</span>
<span>{formatHourShort(23)}</span>
</div>
</div>
{/* Peak hours summary */}
{peakHours.length > 0 && hourlyData[peakHours[0]].count > 0 && (
<div
className="mt-4 pt-3 border-t text-xs"
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
>
Peak hours:{' '}
{peakHours
.filter((h) => hourlyData[h].count > 0)
.map((h) => (
<span
key={h}
className="inline-block px-1.5 py-0.5 rounded mx-0.5"
style={{
backgroundColor: `${theme.colors.accent}20`,
color: theme.colors.accent,
}}
>
{formatHourFull(h)}
</span>
))}
</div>
)}
</div>
);
}
export default TasksByHourChart;

View File

@@ -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<SectionId, string> = {
'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({
</ChartErrorBoundary>
</div>
{/* Agent Efficiency */}
<div
ref={setSectionRef('agent-efficiency')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('agent-efficiency')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Agent Efficiency">
<AgentEfficiencyChart
data={data}
theme={theme}
colorBlindMode={colorBlindMode}
/>
</ChartErrorBoundary>
</div>
{/* Provider Comparison */}
<div
ref={setSectionRef('agent-comparison')}
@@ -1009,6 +1043,33 @@ export function UsageDashboardModal({
/>
</ChartErrorBoundary>
</div>
{/* Weekday vs Weekend Comparison */}
<div
ref={setSectionRef('weekday-comparison')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('weekday-comparison')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Weekday Comparison">
<WeekdayComparisonChart
data={data}
theme={theme}
colorBlindMode={colorBlindMode}
/>
</ChartErrorBoundary>
</div>
<div
ref={setSectionRef('duration-trends')}
tabIndex={0}
@@ -1065,6 +1126,31 @@ export function UsageDashboardModal({
/>
</ChartErrorBoundary>
</div>
{/* Tasks by Time of Day */}
<div
ref={setSectionRef('tasks-by-hour')}
tabIndex={0}
role="region"
aria-label={getSectionLabel('tasks-by-hour')}
onKeyDown={(e) => 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"
>
<ChartErrorBoundary theme={theme} chartName="Tasks by Hour">
<TasksByHourChart
timeRange={timeRange}
theme={theme}
/>
</ChartErrorBoundary>
</div>
</>
)}
</div>

View File

@@ -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 (
<div className="p-4 rounded-lg" style={{ backgroundColor: theme.colors.bgMain }}>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Weekday vs Weekend
</h3>
<div
className="flex items-center justify-center h-24"
style={{ color: theme.colors.textDim }}
>
<span className="text-sm">No daily data available</span>
</div>
</div>
);
}
const maxPercentage = Math.max(
comparisonData.weekday.percentage,
comparisonData.weekend.percentage
);
return (
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgMain }}
data-testid="weekday-comparison-chart"
>
<h3 className="text-sm font-medium mb-4" style={{ color: theme.colors.textMain }}>
Weekday vs Weekend
</h3>
<div className="grid grid-cols-2 gap-6">
{/* Weekday Card */}
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgActivity }}
>
<div className="flex items-center gap-2 mb-3">
<div
className="p-2 rounded-md"
style={{ backgroundColor: `${weekdayColor}20` }}
>
<Briefcase className="w-4 h-4" style={{ color: weekdayColor }} />
</div>
<div>
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
Weekdays
</div>
<div className="text-xs" style={{ color: theme.colors.textDim }}>
Mon - Fri
</div>
</div>
</div>
{/* Bar */}
<div
className="h-3 rounded-full mb-3 overflow-hidden"
style={{ backgroundColor: `${theme.colors.border}30` }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${(comparisonData.weekday.percentage / maxPercentage) * 100}%`,
backgroundColor: weekdayColor,
}}
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Total Queries</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{comparisonData.weekday.totalQueries.toLocaleString()}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Avg/Day</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{comparisonData.weekday.avgQueriesPerDay.toFixed(1)}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Avg Duration</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{formatDuration(comparisonData.weekday.avgDuration)}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Share</span>
<span className="font-medium" style={{ color: weekdayColor }}>
{comparisonData.weekday.percentage.toFixed(1)}%
</span>
</div>
</div>
</div>
{/* Weekend Card */}
<div
className="p-4 rounded-lg"
style={{ backgroundColor: theme.colors.bgActivity }}
>
<div className="flex items-center gap-2 mb-3">
<div
className="p-2 rounded-md"
style={{ backgroundColor: `${weekendColor}20` }}
>
<Coffee className="w-4 h-4" style={{ color: weekendColor }} />
</div>
<div>
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
Weekends
</div>
<div className="text-xs" style={{ color: theme.colors.textDim }}>
Sat - Sun
</div>
</div>
</div>
{/* Bar */}
<div
className="h-3 rounded-full mb-3 overflow-hidden"
style={{ backgroundColor: `${theme.colors.border}30` }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${(comparisonData.weekend.percentage / maxPercentage) * 100}%`,
backgroundColor: weekendColor,
}}
/>
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Total Queries</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{comparisonData.weekend.totalQueries.toLocaleString()}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Avg/Day</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{comparisonData.weekend.avgQueriesPerDay.toFixed(1)}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Avg Duration</span>
<span className="font-medium" style={{ color: theme.colors.textMain }}>
{formatDuration(comparisonData.weekend.avgDuration)}
</span>
</div>
<div className="flex justify-between text-xs">
<span style={{ color: theme.colors.textDim }}>Share</span>
<span className="font-medium" style={{ color: weekendColor }}>
{comparisonData.weekend.percentage.toFixed(1)}%
</span>
</div>
</div>
</div>
</div>
{/* Insight */}
{comparisonData.weekday.days > 0 && comparisonData.weekend.days > 0 && (
<div
className="mt-4 pt-3 border-t text-xs"
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
>
{comparisonData.weekday.avgQueriesPerDay > comparisonData.weekend.avgQueriesPerDay ? (
<span>
You're{' '}
<strong style={{ color: theme.colors.textMain }}>
{(
(comparisonData.weekday.avgQueriesPerDay /
comparisonData.weekend.avgQueriesPerDay) *
100 -
100
).toFixed(0)}
%
</strong>{' '}
more active on weekdays
</span>
) : comparisonData.weekend.avgQueriesPerDay > 0 ? (
<span>
You're{' '}
<strong style={{ color: theme.colors.textMain }}>
{(
(comparisonData.weekend.avgQueriesPerDay /
comparisonData.weekday.avgQueriesPerDay) *
100 -
100
).toFixed(0)}
%
</strong>{' '}
more active on weekends
</span>
) : (
<span>Similar activity on weekdays and weekends</span>
)}
</div>
)}
</div>
);
}
export default WeekdayComparisonChart;

View File

@@ -2132,7 +2132,7 @@ interface MaestroAPI {
}) => Promise<string>;
// 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<string, Array<{ date: string; count: number; duration: number }>>;
}>;
// Export query events to CSV
exportCsv: (range: 'day' | 'week' | 'month' | 'year' | 'all') => Promise<string>;
exportCsv: (range: 'day' | 'week' | 'month' | 'quarter' | 'year' | 'all') => Promise<string>;
// 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<boolean>;
// 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;

View File

@@ -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 {

View File

@@ -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, '');

View File

@@ -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