mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
218
src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx
Normal file
218
src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx
Normal 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;
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 (
|
||||
|
||||
302
src/renderer/components/UsageDashboard/TasksByHourChart.tsx
Normal file
302
src/renderer/components/UsageDashboard/TasksByHourChart.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
10
src/renderer/global.d.ts
vendored
10
src/renderer/global.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 
|
||||
text = text.replace(/!\[[^\]]*\]\([^)]+\)/g, '');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user