Advanced Analytics Dashboard - #27
Conversation
- Implement SpecMetrics and ProductivitySummary data models - Add aggregate_productivity_metrics() to collect cross-spec metrics - Include time saved estimation based on complexity - Support productivity trends over time (daily/weekly/monthly) - Add export functionality for JSON and CSV formats - Follow patterns from metrics_tracker.py and analytics_models.py
- Fix AttributeError when qa_signoff is None
- Use 'or {}' pattern to ensure dict type
- Update implementation plan status to completed
- Update build-progress.txt with session details
- Created productivity-analytics.ts types file matching Python backend models - Added IPC channel constants for productivity analytics operations - Implemented analytics-handlers.ts with handlers for: * getProductivityAnalytics (summary) * getTrends (time-series data) * export (JSON/CSV export) - Registered handlers in IPC handlers index - Follows pattern from merge-analytics-handlers.ts Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Added Activity icon import to Sidebar.tsx - Added 'analytics' to SidebarView type - Added analytics nav item with keyboard shortcut 'T' - Added analytics translation keys (EN and FR) - Imported ProductivityDashboard in App.tsx - Added analytics route rendering ProductivityDashboard - Added productivity analytics types to IPC interface - Added mock implementations in browser-mock.ts - Fixed ProductivityDashboard API calls to match IPC signatures
- Added CLI interface to productivity_analytics.py with --export flag - Supports JSON and CSV export formats with date filtering - Created ProductivityAnalyticsAPI preload module - Integrated into AgentAPI for frontend access - Fixed TypeScript export handler to properly call Python backend - Export generates timestamped files in .auto-claude/analytics/ Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Added window_days and granularity to ProductivityAnalyticsFilter - Fixed type casting in productivity-analytics-api.ts - All TypeScript compilation errors resolved - Backend Python imports verified - Created comprehensive testing documentation
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
8 similar comments
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
PR Summary: Adds a full Advanced Analytics feature: backend aggregator + CLI, main ↔ renderer IPC plumbing, preload API, TypeScript types, mock data, and new dashboard UI (metrics, trends, breakdown, export/refresh). Key changes:
Behavioral/operational notes:
Next steps / considerations:
|
|
Auto review disabled due to large PR. If you still want me to review this PR? Please comment |
2 similar comments
|
Auto review disabled due to large PR. If you still want me to review this PR? Please comment |
|
Auto review disabled due to large PR. If you still want me to review this PR? Please comment |
|
Do you want me to review this PR? Please comment |
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
/review |
| def _parse_timestamp(ts: str | None) -> datetime | None: | ||
| """Parse ISO timestamp string to datetime object.""" | ||
| if not ts: | ||
| return None | ||
| try: | ||
| # Handle both with and without timezone info | ||
| if ts.endswith("Z"): | ||
| return datetime.fromisoformat(ts.replace("Z", "+00:00")) | ||
| return datetime.fromisoformat(ts) | ||
| except (ValueError, AttributeError): | ||
| return None | ||
|
|
||
|
|
||
| def _count_unique_sessions(plan: dict[str, Any]) -> int: | ||
| """ | ||
| Count unique session IDs across all subtasks. | ||
|
|
||
| Args: | ||
| plan: Implementation plan dict | ||
|
|
||
| Returns: | ||
| Number of unique sessions | ||
| """ | ||
| session_ids = set() | ||
|
|
||
| for phase in plan.get("phases", []): | ||
| for subtask in phase.get("subtasks", []): | ||
| session_id = subtask.get("session_id") | ||
| if session_id: | ||
| session_ids.add(session_id) | ||
|
|
||
| return len(session_ids) | ||
|
|
||
|
|
||
| def _estimate_time_saved(spec_metrics: SpecMetrics) -> float: | ||
| """ | ||
| Estimate time saved by AI automation (in hours). | ||
|
|
||
| Uses industry benchmarks: | ||
| - Simple spec: ~2-4 hours manual work | ||
| - Standard spec: ~8-16 hours manual work | ||
| - Complex spec: ~24-40 hours manual work | ||
|
|
||
| Returns conservative estimate based on completed subtasks. | ||
|
|
||
| Args: | ||
| spec_metrics: Spec metrics | ||
|
|
||
| Returns: | ||
| Estimated time saved in hours | ||
| """ | ||
| # Time estimates per subtask (hours) based on complexity | ||
| time_per_subtask = { | ||
| "simple": 0.5, # 30 minutes per subtask | ||
| "standard": 1.5, # 1.5 hours per subtask | ||
| "complex": 3.0, # 3 hours per subtask | ||
| } | ||
|
|
||
| subtask_time = time_per_subtask.get(spec_metrics.complexity, 1.5) | ||
|
|
||
| # Conservative estimate: only count completed subtasks | ||
| estimated_manual_hours = spec_metrics.completed_subtasks * subtask_time | ||
|
|
||
| # AI build time | ||
| ai_build_hours = spec_metrics.duration_seconds / 3600 | ||
|
|
||
| # Time saved = manual time - AI time (but never negative) | ||
| return max(0.0, estimated_manual_hours - ai_build_hours) | ||
|
|
||
|
|
||
| def _extract_spec_metrics(spec_dir: Path) -> SpecMetrics | None: | ||
| """ | ||
| Extract metrics from a single spec directory. | ||
|
|
||
| Args: | ||
| spec_dir: Path to spec directory | ||
|
|
||
| Returns: | ||
| SpecMetrics object or None if invalid | ||
| """ | ||
| plan_file = spec_dir / "implementation_plan.json" | ||
| if not plan_file.exists(): | ||
| return None | ||
|
|
||
| try: | ||
| with open(plan_file, encoding="utf-8") as f: | ||
| plan = json.load(f) | ||
|
|
||
| # Extract basic info | ||
| spec_id = spec_dir.name | ||
| spec_name = plan.get("feature", spec_id) | ||
| workflow_type = plan.get("workflow_type", "feature") | ||
|
|
||
| # Determine complexity from plan or spec metadata | ||
| complexity = "standard" # default | ||
| # Try to infer from subtask count | ||
| total_subtasks = sum( | ||
| len(phase.get("subtasks", [])) for phase in plan.get("phases", []) | ||
| ) | ||
| if total_subtasks <= 3: | ||
| complexity = "simple" | ||
| elif total_subtasks >= 10: | ||
| complexity = "complex" | ||
|
|
||
| # Count subtasks by status | ||
| completed_subtasks = 0 | ||
| failed_subtasks = 0 | ||
| for phase in plan.get("phases", []): | ||
| for subtask in phase.get("subtasks", []): | ||
| status = subtask.get("status", "pending") | ||
| if status == "completed": | ||
| completed_subtasks += 1 | ||
| elif status == "failed": | ||
| failed_subtasks += 1 | ||
|
|
||
| # Determine overall status | ||
| plan_status = plan.get("status", "pending") | ||
| if plan_status == "completed" or ( | ||
| total_subtasks > 0 and completed_subtasks == total_subtasks | ||
| ): | ||
| status = "completed" | ||
| elif completed_subtasks > 0 or any( | ||
| subtask.get("status") == "in_progress" | ||
| for phase in plan.get("phases", []) | ||
| for subtask in phase.get("subtasks", []) | ||
| ): | ||
| status = "in_progress" | ||
| else: | ||
| status = "pending" | ||
|
|
||
| # Time tracking | ||
| created_at = _parse_timestamp(plan.get("created_at")) | ||
| updated_at = _parse_timestamp(plan.get("updated_at")) | ||
|
|
||
| # Calculate duration | ||
| completed_at = None | ||
| duration_seconds = 0.0 | ||
| if created_at: | ||
| if status == "completed" and updated_at: | ||
| completed_at = updated_at | ||
| duration_seconds = (completed_at - created_at).total_seconds() | ||
| elif status == "in_progress": | ||
| # In-progress: duration so far | ||
| duration_seconds = (datetime.now(UTC) - created_at).total_seconds() | ||
|
|
||
| # QA metrics | ||
| qa_signoff = plan.get("qa_signoff") or {} | ||
| qa_iterations = qa_signoff.get("qa_session", 0) | ||
| qa_status = qa_signoff.get("status", "pending") | ||
|
|
||
| # Session count | ||
| unique_sessions = _count_unique_sessions(plan) | ||
|
|
||
| return SpecMetrics( | ||
| spec_id=spec_id, | ||
| spec_name=spec_name, | ||
| workflow_type=workflow_type, | ||
| complexity=complexity, | ||
| status=status, | ||
| created_at=created_at, | ||
| completed_at=completed_at, | ||
| duration_seconds=duration_seconds, | ||
| total_subtasks=total_subtasks, | ||
| completed_subtasks=completed_subtasks, | ||
| failed_subtasks=failed_subtasks, | ||
| qa_iterations=qa_iterations, | ||
| qa_status=qa_status, | ||
| unique_sessions=unique_sessions, | ||
| ) | ||
|
|
||
| except (OSError, json.JSONDecodeError, UnicodeDecodeError) as e: | ||
| return None | ||
|
|
||
|
|
||
| # ============================================================================= | ||
| # PUBLIC API | ||
| # ============================================================================= | ||
|
|
||
|
|
||
| def aggregate_productivity_metrics( | ||
| project_dir: Path, | ||
| start_date: datetime | None = None, | ||
| end_date: datetime | None = None, | ||
| ) -> ProductivitySummary: | ||
| """ | ||
| Aggregate productivity metrics across all specs. | ||
|
|
||
| Args: | ||
| project_dir: Path to project root | ||
| start_date: Optional start date filter (inclusive) | ||
| end_date: Optional end date filter (inclusive) | ||
|
|
||
| Returns: | ||
| ProductivitySummary with aggregated metrics | ||
| """ | ||
| # Locate specs directory | ||
| specs_dir = project_dir / ".auto-claude" / "specs" | ||
| if not specs_dir.exists(): | ||
| # Return empty summary | ||
| return ProductivitySummary( | ||
| period_start=start_date or datetime.now(UTC), | ||
| period_end=end_date or datetime.now(UTC), | ||
| ) | ||
|
|
||
| # Collect all spec metrics | ||
| all_specs: list[SpecMetrics] = [] | ||
| for spec_dir in specs_dir.iterdir(): | ||
| if not spec_dir.is_dir(): | ||
| continue | ||
|
|
||
| spec_metrics = _extract_spec_metrics(spec_dir) | ||
| if not spec_metrics: | ||
| continue | ||
|
|
||
| # Apply date filters |
There was a problem hiding this comment.
[CRITICAL_BUG] Datetime handling mixes naive and timezone-aware datetimes which will raise TypeError when comparing or subtracting (e.g. _parse_timestamp returns naive when input has no tz, then code does datetime.now(UTC) - created_at and compares created_at to start_date). Normalize all parsed timestamps to timezone-aware UTC (e.g. in _parse_timestamp: if parsed dt.tzinfo is None, set tzinfo=UTC or dt = dt.replace(tzinfo=UTC); or always call dt.astimezone(UTC)). Also ensure any start_date/end_date passed into aggregate_productivity_metrics are normalized/validated (interpret naive datetimes as UTC or reject). Add explicit unit-tests for comparisons and duration calculations to cover naive vs aware inputs.
def _parse_timestamp(ts: str | None) -> datetime | None:
"""Parse ISO timestamp string to timezone-aware UTC datetime.
Returns None for invalid or missing timestamps.
"""
if not ts:
return None
try:
# Normalize common Z suffix to explicit UTC offset
if ts.endswith("Z"):
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
else:
dt = datetime.fromisoformat(ts)
# Ensure timezone-aware in UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
else:
dt = dt.astimezone(UTC)
return dt
except (ValueError, AttributeError):
return None
def _normalize_boundary(dt: datetime | None) -> datetime | None:
"""Normalize start/end boundary datetimes to timezone-aware UTC."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=UTC)
return dt.astimezone(UTC)
def aggregate_productivity_metrics(
project_dir: Path,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> ProductivitySummary:
# Normalize boundaries to UTC-aware datetimes to avoid naive/aware comparison
start_date = _normalize_boundary(start_date)
end_date = _normalize_boundary(end_date)
# Locate specs directory
specs_dir = project_dir / ".auto-claude" / "specs"
if not specs_dir.exists():
now = datetime.now(UTC)
return ProductivitySummary(
period_start=start_date or now,
period_end=end_date or now,
)
# Collect all spec metrics
all_specs: list[SpecMetrics] = []
for spec_dir in specs_dir.iterdir():
if not spec_dir.is_dir():
continue
spec_metrics = _extract_spec_metrics(spec_dir)
if not spec_metrics:
continue
# Apply date filters (all timestamps are now UTC-aware)
if start_date and spec_metrics.created_at and spec_metrics.created_at < start_date:
continue
if end_date and spec_metrics.created_at and spec_metrics.created_at > end_date:
continue
all_specs.append(spec_metrics)| // Check if cached file exists and is recent (< 5 minutes old) | ||
| let useCached = false; | ||
| if (await fileExists(summaryFile)) { | ||
| const stats = await fsPromises.stat(summaryFile); | ||
| const fileAge = Date.now() - stats.mtimeMs; | ||
| useCached = fileAge < 5 * 60 * 1000; // 5 minutes | ||
| } | ||
|
|
||
| let summary: ProductivitySummary; | ||
|
|
||
| if (useCached) { |
There was a problem hiding this comment.
[CRITICAL_BUG] Cache logic ignores startDate/endDate filters: the handler may return a cached productivity_summary.json even when callers requested a different date range, leading to incorrect results. Include filter parameters in the cache key (e.g. filename include start/end or hash of args) or only use the shared cache when no filters are supplied. Alternatively invalidate/refresh the cache when date filters are provided.
// Inside PRODUCTIVITY_ANALYTICS_GET_SUMMARY handler
const cacheKeyParts = ['productivity_summary'];
if (startDate) cacheKeyParts.push(`from_${startDate}`);
if (endDate) cacheKeyParts.push(`to_${endDate}`);
const summaryFile = path.join(
analyticsDir,
`${cacheKeyParts.join('__')}.json`
);
// ...rest of logic unchanged| // Extract output path from Python result | ||
| const outputPath = result.output_path || options.output_path; | ||
|
|
||
| if (!outputPath) { | ||
| throw new Error('No output path returned from export operation'); | ||
| } |
There was a problem hiding this comment.
[CRITICAL_BUG] After calling the Python export you assume result.output_path exists (result.output_path || options.output_path). But the Python CLI/handler may return a plain string path rather than an object (see mismatch noted elsewhere). Make this robust by handling both shapes: if typeof result === 'string' use that string, if result has output_path or outputPath use that, else fall back to options.output_path and raise a clear error if none found. Update types to match the agreed shape.
// Type guard helpers
function isExportResultObject(value: unknown): value is { output_path?: string; outputPath?: string } {
return (
!!value &&
(typeof (value as any).output_path === 'string' ||
typeof (value as any).outputPath === 'string')
);
}
// ...inside PRODUCTIVITY_ANALYTICS_EXPORT handler
const result = await executePythonAnalytics(
project.path,
'productivity_analytics.py',
args
);
let outputPath: string | undefined;
if (typeof result === 'string') {
outputPath = result;
} else if (isExportResultObject(result)) {
outputPath = result.output_path || result.outputPath;
} else if (options.output_path) {
outputPath = options.output_path;
}
if (!outputPath) {
throw new Error('No output path returned from export operation');
}
return { success: true, data: outputPath };| export interface ProductivityAnalyticsAPI { | ||
| getProductivitySummary: (projectId: string, filter?: ProductivityAnalyticsFilter) => Promise<IPCResult<ProductivitySummary>>; | ||
| getProductivityTrends: (projectId: string, filter?: ProductivityAnalyticsFilter) => Promise<IPCResult<ProductivityTrendPoint[]>>; | ||
| exportProductivityAnalytics: (projectId: string, options: ProductivityAnalyticsExportOptions) => Promise<IPCResult<{ path: string }>>; | ||
| } | ||
|
|
||
| /** | ||
| * Creates the Productivity Analytics API implementation | ||
| */ | ||
| export const createProductivityAnalyticsAPI = (): ProductivityAnalyticsAPI => ({ | ||
| getProductivitySummary: (projectId: string, filter?: ProductivityAnalyticsFilter): Promise<IPCResult<ProductivitySummary>> => { | ||
| const startDate = filter?.start_date; | ||
| const endDate = filter?.end_date; | ||
| return invokeIpc(IPC_CHANNELS.PRODUCTIVITY_ANALYTICS_GET_SUMMARY, projectId, startDate, endDate); | ||
| }, | ||
|
|
||
| getProductivityTrends: (projectId: string, filter?: ProductivityAnalyticsFilter): Promise<IPCResult<ProductivityTrendPoint[]>> => { | ||
| const windowDays = filter?.window_days || 30; | ||
| const granularity = filter?.granularity || 'daily'; | ||
| return invokeIpc(IPC_CHANNELS.PRODUCTIVITY_ANALYTICS_GET_TRENDS, projectId, windowDays, granularity); | ||
| }, | ||
|
|
||
| exportProductivityAnalytics: (projectId: string, options: ProductivityAnalyticsExportOptions): Promise<IPCResult<{ path: string }>> => { |
There was a problem hiding this comment.
[CRITICAL_BUG] Type/shape mismatch for export API: the API interface declares exportProductivityAnalytics -> Promise<IPCResult<{ path: string }>> but the IPC handler (apps/frontend/src/main/ipc-handlers/analytics-handlers.ts) registers the export handler signature as Promise<IPCResult> and returns a string output path. Align shapes: either make the handler return { output_path: string } (or { path: string }) consistently, or update the API/type to expect a string. Update both sides and tests to ensure serialization/parsing expectations match.
// apps/frontend/src/preload/api/modules/productivity-analytics-api.ts
export interface ProductivityAnalyticsAPI {
getProductivitySummary: (
projectId: string,
filter?: ProductivityAnalyticsFilter
) => Promise<IPCResult<ProductivitySummary>>;
getProductivityTrends: (
projectId: string,
filter?: ProductivityAnalyticsFilter
) => Promise<IPCResult<ProductivityTrendPoint[]>>;
// Align with IPC handler which returns a string path
exportProductivityAnalytics: (
projectId: string,
options: ProductivityAnalyticsExportOptions
) => Promise<IPCResult<string>>;
}
export const createProductivityAnalyticsAPI = (): ProductivityAnalyticsAPI => ({
getProductivitySummary: (
projectId: string,
filter?: ProductivityAnalyticsFilter
): Promise<IPCResult<ProductivitySummary>> => {
const startDate = filter?.start_date;
const endDate = filter?.end_date;
return invokeIpc(
IPC_CHANNELS.PRODUCTIVITY_ANALYTICS_GET_SUMMARY,
projectId,
startDate,
endDate
);
},
getProductivityTrends: (
projectId: string,
filter?: ProductivityAnalyticsFilter
): Promise<IPCResult<ProductivityTrendPoint[]>> => {
const windowDays = filter?.window_days || 30;
const granularity = filter?.granularity || 'daily';
return invokeIpc(
IPC_CHANNELS.PRODUCTIVITY_ANALYTICS_GET_TRENDS,
projectId,
windowDays,
granularity
);
},
exportProductivityAnalytics: (
projectId: string,
options: ProductivityAnalyticsExportOptions
): Promise<IPCResult<string>> => {
return invokeIpc<IPCResult<string>>(
IPC_CHANNELS.PRODUCTIVITY_ANALYTICS_EXPORT,
projectId,
options
);
},
});
// apps/frontend/src/shared/types/ipc.ts
export interface ElectronAPI {
// ...
getProductivitySummary: (
projectId: string,
filter?: ProductivityAnalyticsFilter
) => Promise<IPCResult<ProductivitySummary>>;
getProductivityTrends: (
projectId: string,
filter?: ProductivityAnalyticsFilter
) => Promise<IPCResult<ProductivityTrendPoint[]>>;
// Align shape with IPC handler and API
exportProductivityAnalytics: (
projectId: string,
options: ProductivityAnalyticsExportOptions
) => Promise<IPCResult<string>>;
// ...
}
// apps/frontend/src/renderer/lib/browser-mock.ts
export const browserMockAPI: ElectronAPI = {
// ...
exportProductivityAnalytics: async () => ({
success: true,
data: '/mock/export/productivity',
}),
};| const CHART_METRICS: ChartMetric[] = [ | ||
| { | ||
| key: 'completed_specs', | ||
| label: 'Completed Specs', | ||
| color: 'rgb(34, 197, 94)', // green-500 | ||
| formatValue: (value: number) => value.toFixed(0), | ||
| }, | ||
| { | ||
| key: 'time_saved_hours', | ||
| label: 'Time Saved (hours)', | ||
| color: 'rgb(59, 130, 246)', // blue-500 | ||
| formatValue: (value: number) => value.toFixed(1), | ||
| }, | ||
| { | ||
| key: 'success_rate', | ||
| label: 'Success Rate (%)', | ||
| color: 'rgb(168, 85, 247)', // purple-500 | ||
| formatValue: (value: number) => (value * 100).toFixed(1), | ||
| }, | ||
| ]; | ||
|
|
||
| export function TrendsChart({ trends, isLoading = false }: TrendsChartProps) { | ||
| // Calculate chart dimensions and data | ||
| const chartData = useMemo(() => { | ||
| if (!trends || trends.length === 0) return null; | ||
|
|
||
| const width = 800; | ||
| const height = 300; | ||
| const padding = { top: 20, right: 20, bottom: 40, left: 60 }; | ||
| const chartWidth = width - padding.left - padding.right; | ||
| const chartHeight = height - padding.top - padding.bottom; | ||
|
|
||
| // Process data for each metric | ||
| const processedMetrics = CHART_METRICS.map((metric) => { | ||
| const values = trends.map((point) => { | ||
| if (metric.key === 'success_rate') { | ||
| return point.success_rate * 100; // Convert to percentage | ||
| } | ||
| return point[metric.key] as number; | ||
| }); | ||
|
|
||
| const maxValue = Math.max(...values); | ||
| const minValue = Math.min(...values); | ||
| const range = maxValue - minValue || 1; // Avoid division by zero | ||
|
|
||
| // Generate SVG path | ||
| const points = trends.map((point, index) => { | ||
| const x = padding.left + (index / (trends.length - 1 || 1)) * chartWidth; | ||
| const value = metric.key === 'success_rate' ? point.success_rate * 100 : (point[metric.key] as number); | ||
| const y = padding.top + chartHeight - ((value - minValue) / range) * chartHeight; | ||
| return { x, y, value }; | ||
| }); | ||
|
|
||
| const pathData = points | ||
| .map((point, index) => { | ||
| const command = index === 0 ? 'M' : 'L'; | ||
| return `${command} ${point.x} ${point.y}`; | ||
| }) | ||
| .join(' '); | ||
|
|
||
| // Create area path (for fill) | ||
| const areaPath = `${pathData} L ${points[points.length - 1].x} ${height - padding.bottom} L ${padding.left} ${height - padding.bottom} Z`; | ||
|
|
||
| return { | ||
| ...metric, | ||
| points, | ||
| pathData, | ||
| areaPath, | ||
| maxValue, | ||
| minValue, | ||
| }; | ||
| }); | ||
|
|
||
| // Format dates for x-axis | ||
| const dateLabels = trends.map((point) => { | ||
| const date = new Date(point.date); | ||
| return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); | ||
| }); | ||
|
|
||
| return { | ||
| width, | ||
| height, | ||
| padding, | ||
| chartWidth, | ||
| chartHeight, | ||
| metrics: processedMetrics, | ||
| dateLabels, | ||
| }; | ||
| }, [trends]); | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <div className="rounded-lg border border-border bg-card p-6"> | ||
| <div className="flex items-center gap-2 mb-4"> | ||
| <TrendingUp className="h-5 w-5 text-accent" /> | ||
| <h2 className="text-lg font-semibold text-foreground">Trends Over Time</h2> | ||
| </div> | ||
| <div className="h-80 flex items-center justify-center"> | ||
| <div className="flex flex-col items-center gap-2"> | ||
| <div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-accent" /> | ||
| <p className="text-sm text-muted-foreground">Loading trends...</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| if (!trends || trends.length === 0 || !chartData) { | ||
| return ( | ||
| <div className="rounded-lg border border-border bg-card p-6"> | ||
| <div className="flex items-center gap-2 mb-4"> | ||
| <TrendingUp className="h-5 w-5 text-accent" /> | ||
| <h2 className="text-lg font-semibold text-foreground">Trends Over Time</h2> | ||
| </div> | ||
| <div className="h-80 flex items-center justify-center"> | ||
| <div className="flex flex-col items-center gap-2 text-muted-foreground"> | ||
| <Calendar className="h-12 w-12 opacity-50" /> | ||
| <p className="text-sm">No trend data available</p> | ||
| <p className="text-xs">Complete more tasks to see trends over time</p> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="rounded-lg border border-border bg-card p-6"> | ||
| {/* Header */} | ||
| <div className="flex items-center justify-between mb-6"> | ||
| <div className="flex items-center gap-2"> | ||
| <TrendingUp className="h-5 w-5 text-accent" /> | ||
| <h2 className="text-lg font-semibold text-foreground">Trends Over Time</h2> | ||
| </div> | ||
|
|
||
| {/* Legend */} | ||
| <div className="flex items-center gap-4"> | ||
| {CHART_METRICS.map((metric) => ( | ||
| <div key={metric.key} className="flex items-center gap-2"> | ||
| <div | ||
| className="h-3 w-3 rounded-full" | ||
| style={{ backgroundColor: metric.color }} | ||
| /> | ||
| <span className="text-xs text-muted-foreground">{metric.label}</span> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Chart Container */} | ||
| <div className="w-full overflow-x-auto"> | ||
| <svg | ||
| viewBox={`0 0 ${chartData.width} ${chartData.height}`} | ||
| className="w-full h-auto" | ||
| style={{ minHeight: '300px' }} | ||
| > | ||
| {/* Grid lines */} | ||
| {[0, 0.25, 0.5, 0.75, 1].map((fraction) => { | ||
| const y = chartData.padding.top + chartData.chartHeight * (1 - fraction); | ||
| return ( | ||
| <line | ||
| key={fraction} | ||
| x1={chartData.padding.left} | ||
| y1={y} | ||
| x2={chartData.width - chartData.padding.right} | ||
| y2={y} | ||
| stroke="currentColor" | ||
| strokeWidth="1" | ||
| opacity="0.1" | ||
| className="text-muted-foreground" | ||
| /> | ||
| ); | ||
| })} | ||
|
|
||
| {/* X-axis */} | ||
| <line | ||
| x1={chartData.padding.left} | ||
| y1={chartData.height - chartData.padding.bottom} | ||
| x2={chartData.width - chartData.padding.right} | ||
| y2={chartData.height - chartData.padding.bottom} | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| className="text-border" | ||
| /> | ||
|
|
||
| {/* Y-axis */} | ||
| <line | ||
| x1={chartData.padding.left} | ||
| y1={chartData.padding.top} | ||
| x2={chartData.padding.left} | ||
| y2={chartData.height - chartData.padding.bottom} | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| className="text-border" | ||
| /> | ||
|
|
||
| {/* Date labels (X-axis) */} | ||
| {chartData.dateLabels.map((label, index) => { | ||
| // Show fewer labels on small datasets | ||
| const showLabel = | ||
| index === 0 || | ||
| index === chartData.dateLabels.length - 1 || | ||
| (chartData.dateLabels.length > 5 && index % Math.ceil(chartData.dateLabels.length / 5) === 0); | ||
|
|
||
| if (!showLabel) return null; | ||
|
|
||
| const x = chartData.padding.left + (index / (trends.length - 1 || 1)) * chartData.chartWidth; | ||
| return ( | ||
| <text | ||
| key={index} | ||
| x={x} | ||
| y={chartData.height - chartData.padding.bottom + 20} | ||
| textAnchor="middle" | ||
| fontSize="10" | ||
| className="fill-muted-foreground" | ||
| > | ||
| {label} | ||
| </text> | ||
| ); | ||
| })} | ||
|
|
||
| {/* Plot lines for each metric */} | ||
| {chartData.metrics.map((metric) => ( | ||
| <g key={metric.key}> | ||
| {/* Area fill */} | ||
| <path | ||
| d={metric.areaPath} | ||
| fill={metric.color} | ||
| opacity="0.1" | ||
| /> | ||
|
|
||
| {/* Line */} | ||
| <path | ||
| d={metric.pathData} | ||
| fill="none" | ||
| stroke={metric.color} | ||
| strokeWidth="2" | ||
| strokeLinejoin="round" | ||
| strokeLinecap="round" | ||
| /> | ||
|
|
||
| {/* Data points */} | ||
| {metric.points.map((point, index) => ( | ||
| <g key={index}> | ||
| <circle | ||
| cx={point.x} | ||
| cy={point.y} | ||
| r="4" | ||
| fill={metric.color} | ||
| className="hover:r-6 transition-all" | ||
| /> | ||
| {/* Tooltip on hover (simplified) */} | ||
| <title> | ||
| {chartData.dateLabels[index]}: {metric.formatValue(point.value)} {metric.label} | ||
| </title> | ||
| </g> | ||
| ))} | ||
| </g> | ||
| ))} | ||
| </svg> | ||
| </div> | ||
|
|
||
| {/* Summary Stats Below Chart */} | ||
| <div className="mt-6 grid grid-cols-3 gap-4 pt-4 border-t border-border"> | ||
| {CHART_METRICS.map((metric) => { | ||
| const metricData = chartData.metrics.find((m) => m.key === metric.key); | ||
| if (!metricData) return null; | ||
|
|
||
| const latestValue = trends[trends.length - 1][metric.key] as number; |
There was a problem hiding this comment.
[CRITICAL_BUG] Inconsistent handling of success_rate causes double-percent conversion and incorrect chart labels/values. Details: CHART_METRICS.formatValue (lines ~19-37) multiplies the input by 100 expecting a 0..1 value, but chart data processing converts success_rate to a percentage (point.success_rate * 100) at lines ~52-58 and the UI 'displayValue' multiplies again at ~286-287. Pick one canonical representation: either keep success_rate as 0..1 everywhere and multiply only in the final formatter, or convert it to percentage (0..100) once during processing and update CHART_METRICS.formatValue to assume percentage input (remove the extra *100). Update all uses (points.value, tooltip text, summary display, and range formatting) to use the same representation to avoid large/wrong numbers.
const CHART_METRICS: ChartMetric[] = [
{
key: 'completed_specs',
label: 'Completed Specs',
color: 'rgb(34, 197, 94)', // green-500
formatValue: (value: number) => value.toFixed(0),
},
{
key: 'time_saved_hours',
label: 'Time Saved (hours)',
color: 'rgb(59, 130, 246)', // blue-500
formatValue: (value: number) => value.toFixed(1),
},
{
key: 'success_rate',
label: 'Success Rate (%)',
color: 'rgb(168, 85, 247)', // purple-500
// success_rate is already a 0..1 value; keep it that way everywhere
formatValue: (value: number) => (value * 100).toFixed(1),
},
];
export function TrendsChart({ trends, isLoading = false }: TrendsChartProps) {
const chartData = useMemo(() => {
if (!trends || trends.length === 0) return null;
const width = 800;
const height = 300;
const padding = { top: 20, right: 20, bottom: 40, left: 60 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const processedMetrics = CHART_METRICS.map((metric) => {
const values = trends.map((point) => point[metric.key] as number);
const maxValue = Math.max(...values);
const minValue = Math.min(...values);
const range = maxValue - minValue || 1;
const steps = Math.max(1, trends.length - 1);
const points = trends.map((point, index) => {
const x = padding.left + (index / steps) * chartWidth;
const value = point[metric.key] as number;
const y = padding.top +
chartHeight -
((value - minValue) / range) * chartHeight;
return { x, y, value };
});
const pathData = points
.map((point, index) => {
const command = index === 0 ? 'M' : 'L';
return `${command} ${point.x} ${point.y}`;
})
.join(' ');
const areaPath = `${pathData} L ${points[points.length - 1].x} ${height - padding.bottom} L ${padding.left} ${height - padding.bottom} Z`;
return {
...metric,
points,
pathData,
areaPath,
maxValue,
minValue,
};
});
const dateLabels = trends.map((point) => {
const date = new Date(point.date);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
});
return {
width,
height,
padding,
chartWidth,
chartHeight,
metrics: processedMetrics,
dateLabels,
};
}, [trends]);
// ...
<div className="mt-6 grid grid-cols-3 gap-4 pt-4 border-t border-border">
{CHART_METRICS.map((metric) => {
const metricData = chartData.metrics.find((m) => m.key === metric.key);
if (!metricData) return null;
const latestValue = trends[trends.length - 1][metric.key] as number;
return (
<div key={metric.key} className="text-center">
<p className="text-xs text-muted-foreground mb-1">{metric.label}</p>
<p className="text-xl font-bold" style={{ color: metric.color }}>
{metric.formatValue(latestValue)}
</p>
<p className="text-xs text-muted-foreground">
Range: {metric.formatValue(metricData.minValue)} - {metric.formatValue(metricData.maxValue)}
</p>
</div>
);
})}
</div>
}| {/* Empty State */} | ||
| {!summary || summary.total_specs === 0 && ( | ||
| <div className="flex flex-col items-center justify-center h-64 text-muted-foreground"> | ||
| <BarChart3 className="h-16 w-16 mb-4 opacity-50" /> | ||
| <p className="text-lg font-medium">No productivity data available</p> | ||
| <p className="text-sm">Complete some tasks to see your analytics</p> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
[CRITICAL_BUG] Empty state conditional uses mixed || and && without grouping which prevents the empty-state JSX from rendering when summary is null. Change the expression to explicitly group conditions, e.g. {( !summary || summary.total_specs === 0 ) && (
// Empty State
{(!summary || summary.total_specs === 0) && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<BarChart3 className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">No productivity data available</p>
<p className="text-sm">Complete some tasks to see your analytics</p>
</div>
)}| <p className="text-sm text-muted-foreground">Time Saved</p> | ||
| <p className="text-2xl font-bold">{summary.total_time_saved_hours.toFixed(1)}h</p> | ||
| </div> | ||
| <div> | ||
| <p className="text-sm text-muted-foreground">Success Rate</p> | ||
| <p className="text-2xl font-bold"> | ||
| {(summary.average_success_rate * 100).toFixed(1)}% | ||
| </p> |
There was a problem hiding this comment.
[VALIDATION] You call toFixed() and multiply numeric fields (e.g., summary.total_time_saved_hours.toFixed(1) and (summary.average_success_rate * 100).toFixed(1)). Although types say these are numbers, the backend may return undefined/null in edge cases and calling toFixed (or *100) would throw. Add safe defaults (e.g., Number(summary.total_time_saved_hours ?? 0).toFixed(1) and guard average_success_rate) or do optional chaining + fallback to avoid runtime exceptions.
{summary && (
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-lg font-semibold mb-4">Summary Metrics</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">Total Specs</p>
<p className="text-2xl font-bold">{summary.total_specs ?? 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Completed</p>
<p className="text-2xl font-bold text-green-600">{summary.completed_specs ?? 0}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Time Saved</p>
<p className="text-2xl font-bold">
{Number(summary.total_time_saved_hours ?? 0).toFixed(1)}h
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Success Rate</p>
<p className="text-2xl font-bold">
{Number((summary.average_success_rate ?? 0) * 100).toFixed(1)}%
</p>
</div>
</div>
</div>
)}| getProductivitySummary: async () => ({ | ||
| success: true, | ||
| data: { | ||
| period_start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), | ||
| period_end: new Date().toISOString(), | ||
| total_specs: 0, | ||
| completed_specs: 0, | ||
| in_progress_specs: 0, | ||
| failed_specs: 0, | ||
| total_time_saved_hours: 0, | ||
| total_build_time_hours: 0, | ||
| average_success_rate: 0, | ||
| first_attempt_success_rate: 0, | ||
| specs_by_type: {}, | ||
| specs_by_complexity: {}, | ||
| average_subtasks_per_spec: 0, | ||
| average_qa_iterations: 0, | ||
| total_subtasks_completed: 0, | ||
| specs: [] | ||
| } | ||
| }), | ||
| getProductivityTrends: async () => ({ success: true, data: [] }), | ||
| exportProductivityAnalytics: async () => ({ success: true, data: { path: '/mock/export/productivity' } }) |
There was a problem hiding this comment.
[REFACTORING] You added productivity analytics mocks which is good for browser preview. Make the mocks closer to real payloads: accept (projectId, filter) parameters and honor filter.start_date/end_date and window_days when generating trend points; return sensible non-null defaults for numeric fields to match TypeScript expectations. This reduces surprizes when switching from browser-preview to Electron (and helps tests).
// In apps/frontend/src/renderer/lib/browser-mock.ts
// Productivity analytics operations
getProductivitySummary: async (
_projectId?: string,
filter?: import('../../shared/types').ProductivityAnalyticsFilter
) => {
const now = Date.now();
const windowDays = filter?.window_days ?? 30;
const periodEnd = filter?.end_date ? new Date(filter.end_date).getTime() : now;
const periodStart = filter?.start_date
? new Date(filter.start_date).getTime()
: periodEnd - windowDays * 24 * 60 * 60 * 1000;
const periodStartIso = new Date(periodStart).toISOString();
const periodEndIso = new Date(periodEnd).toISOString();
return {
success: true,
data: {
period_start: periodStartIso,
period_end: periodEndIso,
total_specs: 0,
completed_specs: 0,
in_progress_specs: 0,
failed_specs: 0,
total_time_saved_hours: 0,
total_build_time_hours: 0,
average_success_rate: 0,
first_attempt_success_rate: 0,
specs_by_type: {},
specs_by_complexity: {},
average_subtasks_per_spec: 0,
average_qa_iterations: 0,
total_subtasks_completed: 0,
specs: [],
},
};
},
getProductivityTrends: async (
_projectId?: string,
filter?: import('../../shared/types').ProductivityAnalyticsFilter
) => {
const windowDays = filter?.window_days ?? 30;
const end = filter?.end_date ? new Date(filter.end_date) : new Date();
const start = filter?.start_date
? new Date(filter.start_date)
: new Date(end.getTime() - windowDays * 24 * 60 * 60 * 1000);
// Return a minimal but realistic single-point trend so charts have data
const point: import('../../shared/types').ProductivityTrendPoint = {
date: start.toISOString(),
total_specs: 1,
completed_specs: 1,
time_saved_hours: 1,
success_rate: 1,
};
return { success: true, data: [point] };
},
exportProductivityAnalytics: async (
_projectId?: string,
_options?: import('../../shared/types').ProductivityAnalyticsExportOptions
) => ({
success: true,
data: { path: '/mock/export/productivity' },
}),| // Filter specs based on all criteria | ||
| const filteredSpecs = specs.filter((spec) => { | ||
| // Apply search filter | ||
| const matchesSearch = | ||
| spec.spec_name.toLowerCase().includes(searchQuery.toLowerCase()) || | ||
| spec.spec_id.toLowerCase().includes(searchQuery.toLowerCase()) || | ||
| spec.workflow_type.toLowerCase().includes(searchQuery.toLowerCase()); | ||
|
|
||
| // Apply status filter | ||
| const matchesStatus = | ||
| statusFilter === 'all' || | ||
| spec.status.toLowerCase() === statusFilter.toLowerCase(); | ||
|
|
||
| // Apply complexity filter | ||
| const matchesComplexity = | ||
| complexityFilter === 'all' || | ||
| spec.complexity.toLowerCase() === complexityFilter.toLowerCase(); | ||
|
|
||
| // Apply type filter | ||
| const matchesType = | ||
| typeFilter === 'all' || | ||
| spec.workflow_type.toLowerCase() === typeFilter.toLowerCase(); | ||
|
|
||
| return matchesSearch && matchesStatus && matchesComplexity && matchesType; | ||
| }); |
There was a problem hiding this comment.
[PERFORMANCE_OPTIMIZATION] filteredSpecs recalculates searchQuery.toLowerCase() multiple times per spec. Precompute a lower-cased search string once (const q = searchQuery.toLowerCase()) and reuse it when checking spec fields to reduce allocations and improve readability.
// Filter specs based on all criteria
const filteredSpecs = specs.filter((spec) => {
const q = searchQuery.toLowerCase();
// Apply search filter
const matchesSearch =
spec.spec_name.toLowerCase().includes(q) ||
spec.spec_id.toLowerCase().includes(q) ||
spec.workflow_type.toLowerCase().includes(q);
// Apply status filter
const matchesStatus =
statusFilter === 'all' ||
spec.status.toLowerCase() === statusFilter.toLowerCase();
// Apply complexity filter
const matchesComplexity =
complexityFilter === 'all' ||
spec.complexity.toLowerCase() === complexityFilter.toLowerCase();
// Apply type filter
const matchesType =
typeFilter === 'all' ||
spec.workflow_type.toLowerCase() === typeFilter.toLowerCase();
return matchesSearch && matchesStatus && matchesComplexity && matchesType;
});|
Reviewed up to commit:df55a069acf08718f4f9e74b820f3bd85937b992 Additional Suggestionapps/frontend/src/main/ipc-handlers/analytics-handlers.ts, line:36-61Spawning the Python process uses a heuristic ('python' on Windows, 'python3' elsewhere) which is brittle across environments and virtualenvs. Prefer resolving the project's Python path via a configuration or pythonEnvManager (already present elsewhere in the app) or allow callers to override the interpreter. Also consider adding a timeout and explicit error handling/cleanup for long-running/hung Python processes to avoid leaking processes or UI freezes.async function executePythonAnalytics(
projectPath: string,
scriptName: string,
args: string[] = [],
pythonPathOverride?: string,
timeoutMs = 60_000
): Promise<unknown> {
return new Promise((resolve, reject) => {
const pythonPath = pythonPathOverride
|| process.env.AUTOCLAUDE_PYTHON
|| (process.platform === 'win32' ? 'python' : 'python3');
const scriptPath = path.join(projectPath, 'apps', 'backend', 'analysis', scriptName);
const proc = spawn(pythonPath, [scriptPath, ...args], {
cwd: projectPath,
env: { ...process.env, PYTHONPATH: path.join(projectPath, 'apps', 'backend') },
});
let stdout = '';
let stderr = '';
const timeout = setTimeout(() => {
proc.kill();
reject(new Error(`Python analytics timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.on('data', (data) => {
stdout += data.toString();
});
proc.stderr.on('data', (data) => {
stderr += data.toString();
});
proc.on('close', (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(new Error(`Python script failed: ${stderr || `exit code ${code}`}`));
return;
}
try {
const result = JSON.parse(stdout);
resolve(result);
} catch (error) {
reject(new Error(`Failed to parse Python output: ${error}`));
}
});
proc.on('error', (error) => {
clearTimeout(timeout);
reject(new Error(`Failed to spawn Python process: ${error.message}`));
});
});
}Few more points:
# In apps/backend/cli/main.py within _parse_args()
# Rename the existing merge analytics output flag to avoid confusion
parser.add_argument(
"--merge-analytics-output",
type=str,
default=None,
metavar="FILE",
help="Output file for merge analytics export",
)
# Keep the new productivity analytics flag clearly separate
parser.add_argument(
"--analytics-export-path",
type=str,
default=None,
metavar="FILE",
help="Export productivity analytics to file (with --analytics)",
)
# In handle_merge_analytics_export_command call
if args.merge_analytics_export:
handle_merge_analytics_export_command(
project_dir,
output_path=args.merge_analytics_output,
format=args.analytics_format,
)
return
# In productivity analytics handler, keep using args.analytics_export_path
if args.analytics:
export_path = Path(args.analytics_export_path) if args.analytics_export_path else None
handle_analytics_command(
project_dir=project_dir,
trends=args.analytics_trends,
days=args.analytics_days,
granularity=args.analytics_granularity,
export_path=export_path,
export_format=args.analytics_format,
)
return |
- Use getConfiguredPythonPath/parsePythonCommand/getAugmentedEnv in analytics-handlers instead of hardcoded python path - Fix operator precedence bug in ProductivityDashboard empty state - Clarify sys.path comment in analytics_commands.py Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix naive/aware datetime mixing in _parse_timestamp (normalize to UTC) - Add _normalize_boundary for start_date/end_date params - Include date filters in analytics cache key to avoid stale results - Handle both string and object shapes in export result parsing - Align export API type to IPCResult<string> across all layers - Fix double-percent conversion of success_rate in TrendsChart - Add null guards (??0) on numeric fields in ProductivityDashboard - Improve browser mocks with filter-aware params and proper types - Precompute toLowerCase() in SpecBreakdownTable filter loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Keep both productivity-analytics and template-library additions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Create a dashboard showing build statistics, success rates, time savings, and productivity metrics. Help users understand the value Auto Claude provides.