Skip to content

Commit de1644d

Browse files
committed
feat: add lifecycle analytics charts
1 parent a1d1699 commit de1644d

File tree

4 files changed

+287
-4
lines changed

4 files changed

+287
-4
lines changed

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ This roadmap is derived from deep research into Greptile's public docs, blog, MC
104104
62. [x] Add acceptance/rejection trend lines over time for recent reviews.
105105
63. [x] Add top accepted categories/rules and top rejected categories/rules to Analytics.
106106
64. [x] Add unresolved blocker counts per repository and per PR.
107-
65. [ ] Add review completeness and mean-time-to-resolution charts.
107+
65. [x] Add review completeness and mean-time-to-resolution charts.
108108
66. [ ] Add feedback-learning effectiveness metrics: did reranked findings get higher acceptance after rollout?
109109
67. [ ] Add pattern-repository utilization analytics showing when extra context actually affected findings.
110110
68. [ ] Add eval-vs-production dashboards comparing benchmark strength against real-world acceptance.

web/src/lib/analytics.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ import type {
66
} from '../api/types'
77

88
export function computeAnalytics(reviews: ReviewSession[]) {
9-
const reviewTimestamp = (value: ReviewSession['started_at']) =>
10-
typeof value === 'number' ? value : Date.parse(value)
9+
const reviewTimestamp = (value: ReviewSession['started_at']) => toTimestampMs(value) ?? 0
1110

1211
const completed = reviews
1312
.filter(r => r.status === 'Complete' && r.summary)
@@ -145,6 +144,48 @@ export function computeAnalytics(reviews: ReviewSession[]) {
145144
openBlockers: r.summary!.open_blockers,
146145
}))
147146

147+
const completenessSeries = completed.map((r, i) => {
148+
const completeness = getCompletenessSummary(r.summary!)
149+
const totalFindings = completeness.total_findings
150+
151+
return {
152+
idx: i + 1,
153+
label: `#${i + 1}`,
154+
totalFindings,
155+
acknowledged: completeness.acknowledged_findings,
156+
fixed: completeness.fixed_findings,
157+
stale: completeness.stale_findings,
158+
acknowledgedRate: totalFindings > 0 ? completeness.acknowledged_findings / totalFindings : 0,
159+
fixedRate: totalFindings > 0 ? completeness.fixed_findings / totalFindings : 0,
160+
}
161+
})
162+
163+
const meanTimeToResolutionSeries = completed.map((r, i) => {
164+
const startedAtMs = toTimestampMs(r.started_at)
165+
const resolutionHours = startedAtMs == null
166+
? []
167+
: r.comments.flatMap(comment => {
168+
if (comment.status !== 'Resolved') {
169+
return []
170+
}
171+
172+
const resolvedAtMs = toTimestampMs(comment.resolved_at)
173+
if (resolvedAtMs == null || resolvedAtMs < startedAtMs) {
174+
return []
175+
}
176+
177+
return [(resolvedAtMs - startedAtMs) / (1000 * 60 * 60)]
178+
})
179+
const totalHours = resolutionHours.reduce((sum, hours) => sum + hours, 0)
180+
181+
return {
182+
idx: i + 1,
183+
label: `#${i + 1}`,
184+
meanHours: resolutionHours.length > 0 ? totalHours / resolutionHours.length : null,
185+
resolvedCount: resolutionHours.length,
186+
}
187+
})
188+
148189
const totalFindings = completed.reduce((s, r) => s + r.summary!.total_comments, 0)
149190
const avgFindings = completed.length > 0 ? totalFindings / completed.length : 0
150191
const avgScore = completed.length > 0
@@ -154,6 +195,31 @@ export function computeAnalytics(reviews: ReviewSession[]) {
154195
const totalResolvedComments = completed.reduce((sum, r) => sum + r.summary!.resolved_comments, 0)
155196
const totalDismissedComments = completed.reduce((sum, r) => sum + r.summary!.dismissed_comments, 0)
156197
const totalOpenBlockers = completed.reduce((sum, r) => sum + r.summary!.open_blockers, 0)
198+
const totalAcknowledgedFindings = completed.reduce(
199+
(sum, r) => sum + getCompletenessSummary(r.summary!).acknowledged_findings,
200+
0,
201+
)
202+
const totalFixedFindings = completed.reduce(
203+
(sum, r) => sum + getCompletenessSummary(r.summary!).fixed_findings,
204+
0,
205+
)
206+
const totalStaleFindings = completed.reduce(
207+
(sum, r) => sum + getCompletenessSummary(r.summary!).stale_findings,
208+
0,
209+
)
210+
const totalCompletenessFindings = completed.reduce(
211+
(sum, r) => sum + getCompletenessSummary(r.summary!).total_findings,
212+
0,
213+
)
214+
const resolvedWithTimestampCount = meanTimeToResolutionSeries.reduce(
215+
(sum, point) => sum + point.resolvedCount,
216+
0,
217+
)
218+
const totalResolutionHours = meanTimeToResolutionSeries.reduce(
219+
(sum, point) => sum + (point.meanHours ?? 0) * point.resolvedCount,
220+
0,
221+
)
222+
const reviewsWithTimedResolutions = meanTimeToResolutionSeries.filter(point => point.resolvedCount > 0).length
157223
const totalLifecycleComments = totalOpenComments + totalResolvedComments + totalDismissedComments
158224
const labeledFeedbackTotal = feedbackCoverageSeries.reduce((sum, point) => sum + point.labeled, 0)
159225
const acceptedFeedbackTotal = feedbackCoverageSeries.reduce((sum, point) => sum + point.accepted, 0)
@@ -176,6 +242,8 @@ export function computeAnalytics(reviews: ReviewSession[]) {
176242
severityOverTime,
177243
categoryData,
178244
lifecycleSeries,
245+
completenessSeries,
246+
meanTimeToResolutionSeries,
179247
feedbackCoverageSeries,
180248
topAcceptedCategories,
181249
topRejectedCategories,
@@ -196,6 +264,15 @@ export function computeAnalytics(reviews: ReviewSession[]) {
196264
totalResolvedComments,
197265
totalDismissedComments,
198266
totalOpenBlockers,
267+
totalAcknowledgedFindings,
268+
totalFixedFindings,
269+
totalStaleFindings,
270+
completenessRate: totalCompletenessFindings > 0 ? totalAcknowledgedFindings / totalCompletenessFindings : 0,
271+
meanTimeToResolutionHours: resolvedWithTimestampCount > 0
272+
? totalResolutionHours / resolvedWithTimestampCount
273+
: null,
274+
resolvedWithTimestampCount,
275+
reviewsWithTimedResolutions,
199276
resolutionRate: totalLifecycleComments > 0
200277
? (totalResolvedComments + totalDismissedComments) / totalLifecycleComments
201278
: 0,
@@ -216,6 +293,19 @@ export function formatPercent(value: number | undefined): string {
216293
return value == null ? 'n/a' : `${(value * 100).toFixed(0)}%`
217294
}
218295

296+
export function formatDurationHours(value: number | null | undefined): string {
297+
if (value == null || Number.isNaN(value)) {
298+
return 'n/a'
299+
}
300+
if (value >= 48) {
301+
return `${(value / 24).toFixed(1)}d`
302+
}
303+
if (value >= 1) {
304+
return `${value.toFixed(1)}h`
305+
}
306+
return `${Math.round(value * 60)}m`
307+
}
308+
219309
export function computeTrendAnalytics(trends: AnalyticsTrendsResponse | undefined) {
220310
const evalEntries = trends?.eval_trend.entries ?? []
221311
const feedbackEntries = trends?.feedback_eval_trend.entries ?? []
@@ -290,9 +380,17 @@ export interface AnalyticsExportReport {
290380
totalResolvedComments: number
291381
totalDismissedComments: number
292382
totalOpenBlockers: number
383+
totalAcknowledgedFindings: number
384+
totalFixedFindings: number
385+
totalStaleFindings: number
386+
completenessRate: number
387+
meanTimeToResolutionHours?: number
388+
resolvedWithTimestampCount: number
293389
resolutionRate: number
294390
}
295391
byReview: AnalyticsSnapshot['lifecycleSeries']
392+
completenessByReview: AnalyticsSnapshot['completenessSeries']
393+
meanTimeToResolutionByReview: AnalyticsSnapshot['meanTimeToResolutionSeries']
296394
}
297395
reinforcement: {
298396
summary: {
@@ -401,9 +499,17 @@ export function buildAnalyticsExportReport(
401499
totalResolvedComments: analytics.stats.totalResolvedComments,
402500
totalDismissedComments: analytics.stats.totalDismissedComments,
403501
totalOpenBlockers: analytics.stats.totalOpenBlockers,
502+
totalAcknowledgedFindings: analytics.stats.totalAcknowledgedFindings,
503+
totalFixedFindings: analytics.stats.totalFixedFindings,
504+
totalStaleFindings: analytics.stats.totalStaleFindings,
505+
completenessRate: analytics.stats.completenessRate,
506+
meanTimeToResolutionHours: analytics.stats.meanTimeToResolutionHours ?? undefined,
507+
resolvedWithTimestampCount: analytics.stats.resolvedWithTimestampCount,
404508
resolutionRate: analytics.stats.resolutionRate,
405509
},
406510
byReview: analytics.lifecycleSeries,
511+
completenessByReview: analytics.completenessSeries,
512+
meanTimeToResolutionByReview: analytics.meanTimeToResolutionSeries,
407513
},
408514
reinforcement: {
409515
summary: {
@@ -463,6 +569,20 @@ export function buildAnalyticsCsv(report: AnalyticsExportReport): string {
463569
rows.push({ report: 'lifecycle', group: 'by_review', label: point.label, metric: 'dismissed', value: point.dismissed })
464570
rows.push({ report: 'lifecycle', group: 'by_review', label: point.label, metric: 'open_blockers', value: point.openBlockers })
465571
})
572+
report.lifecycle.completenessByReview.forEach(point => {
573+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'total_findings', value: point.totalFindings })
574+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'acknowledged', value: point.acknowledged })
575+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'fixed', value: point.fixed })
576+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'stale', value: point.stale })
577+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'acknowledged_rate', value: point.acknowledgedRate })
578+
rows.push({ report: 'lifecycle', group: 'completeness_by_review', label: point.label, metric: 'fixed_rate', value: point.fixedRate })
579+
})
580+
report.lifecycle.meanTimeToResolutionByReview.forEach(point => {
581+
if (point.meanHours != null) {
582+
rows.push({ report: 'lifecycle', group: 'mean_time_to_resolution_by_review', label: point.label, metric: 'mean_hours', value: point.meanHours })
583+
}
584+
rows.push({ report: 'lifecycle', group: 'mean_time_to_resolution_by_review', label: point.label, metric: 'resolved_count', value: point.resolvedCount })
585+
})
466586

467587
appendSummaryRows(rows, 'reinforcement', 'summary', report.reinforcement.summary)
468588
report.reinforcement.coverageByReview.forEach(point => {
@@ -515,3 +635,27 @@ export function exportAnalyticsJson(report: AnalyticsExportReport) {
515635
'diffscope-analytics-report.json',
516636
)
517637
}
638+
639+
function toDate(value: string | number | undefined): Date | null {
640+
if (value == null) {
641+
return null
642+
}
643+
644+
const date = typeof value === 'number'
645+
? new Date(value * 1000)
646+
: new Date(value)
647+
return Number.isNaN(date.getTime()) ? null : date
648+
}
649+
650+
function toTimestampMs(value: string | number | undefined): number | null {
651+
return toDate(value)?.getTime() ?? null
652+
}
653+
654+
function getCompletenessSummary(summary: NonNullable<ReviewSession['summary']>) {
655+
return summary.completeness ?? {
656+
total_findings: summary.total_comments,
657+
acknowledged_findings: summary.resolved_comments + summary.dismissed_comments,
658+
fixed_findings: summary.resolved_comments,
659+
stale_findings: 0,
660+
}
661+
}

web/src/pages/Analytics.tsx

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
computeTrendAnalytics,
1313
exportAnalyticsCsv,
1414
exportAnalyticsJson,
15+
formatDurationHours,
1516
formatPercent,
1617
} from '../lib/analytics'
1718
import { scoreColorClass } from '../lib/scores'
@@ -113,6 +114,8 @@ export function Analytics() {
113114
scoreOverTime,
114115
severityOverTime,
115116
categoryData,
117+
completenessSeries,
118+
meanTimeToResolutionSeries,
116119
feedbackCoverageSeries,
117120
topAcceptedCategories,
118121
topRejectedCategories,
@@ -344,6 +347,109 @@ export function Analytics() {
344347
</div>
345348
</div>
346349

350+
<div className="text-[10px] font-semibold text-text-muted tracking-[0.08em] font-code mt-8 mb-3">
351+
LIFECYCLE FOLLOW-THROUGH
352+
</div>
353+
354+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
355+
<div className="bg-surface-1 border border-border rounded-lg p-4">
356+
<div className="text-[10px] font-semibold text-text-muted tracking-[0.08em] font-code mb-3">
357+
COMPLETENESS TREND
358+
</div>
359+
<div className="flex items-baseline gap-2 mb-1">
360+
<span className="text-2xl font-bold font-code text-accent">
361+
{formatPercent(stats.completenessRate)}
362+
</span>
363+
<span className="text-[11px] text-text-muted">acknowledged of findings</span>
364+
</div>
365+
<div className="h-32 mt-2">
366+
<ResponsiveContainer width="100%" height="99%" minWidth={50} minHeight={50}>
367+
<AreaChart data={completenessSeries}>
368+
<defs>
369+
<linearGradient id="completenessAckGrad" x1="0" y1="0" x2="0" y2="1">
370+
<stop offset="5%" stopColor={CHART_THEME.accent} stopOpacity={0.35} />
371+
<stop offset="95%" stopColor={CHART_THEME.accent} stopOpacity={0.05} />
372+
</linearGradient>
373+
<linearGradient id="completenessFixedGrad" x1="0" y1="0" x2="0" y2="1">
374+
<stop offset="5%" stopColor={SEV_COLORS.Suggestion} stopOpacity={0.25} />
375+
<stop offset="95%" stopColor={SEV_COLORS.Suggestion} stopOpacity={0.04} />
376+
</linearGradient>
377+
</defs>
378+
<CartesianGrid {...gridProps} />
379+
<XAxis dataKey="label" tick={axisTick} axisLine={false} tickLine={false} />
380+
<YAxis domain={[0, 1]} tick={axisTick} axisLine={false} tickLine={false} />
381+
<Tooltip {...tooltipStyle} />
382+
<Area type="monotone" dataKey="acknowledgedRate" stroke={CHART_THEME.accent} fill="url(#completenessAckGrad)" strokeWidth={1.5} dot={false} name="Acknowledged rate" />
383+
<Area type="monotone" dataKey="fixedRate" stroke={SEV_COLORS.Suggestion} fill="url(#completenessFixedGrad)" strokeWidth={1.5} dot={false} name="Fixed rate" />
384+
</AreaChart>
385+
</ResponsiveContainer>
386+
</div>
387+
<div className="flex items-center gap-6 mt-3 pt-3 border-t border-border-subtle">
388+
<div className="text-center">
389+
<div className="text-sm font-bold font-code text-text-primary">{stats.totalAcknowledgedFindings}</div>
390+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">ACKNOWLEDGED</div>
391+
</div>
392+
<div className="text-center">
393+
<div className="text-sm font-bold font-code text-sev-suggestion">{stats.totalFixedFindings}</div>
394+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">FIXED</div>
395+
</div>
396+
<div className="text-center">
397+
<div className="text-sm font-bold font-code text-sev-warning">{stats.totalStaleFindings}</div>
398+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">STALE</div>
399+
</div>
400+
</div>
401+
</div>
402+
403+
<div className="bg-surface-1 border border-border rounded-lg p-4">
404+
<div className="text-[10px] font-semibold text-text-muted tracking-[0.08em] font-code mb-3">
405+
MEAN TIME TO RESOLUTION
406+
</div>
407+
<div className="flex items-baseline gap-2 mb-1">
408+
<span className="text-2xl font-bold font-code text-sev-suggestion">
409+
{formatDurationHours(stats.meanTimeToResolutionHours)}
410+
</span>
411+
<span className="text-[11px] text-text-muted">avg for fixed findings</span>
412+
</div>
413+
{stats.resolvedWithTimestampCount > 0 ? (
414+
<div className="h-32 mt-2">
415+
<ResponsiveContainer width="100%" height="99%" minWidth={50} minHeight={50}>
416+
<AreaChart data={meanTimeToResolutionSeries}>
417+
<defs>
418+
<linearGradient id="mttrGrad" x1="0" y1="0" x2="0" y2="1">
419+
<stop offset="5%" stopColor={SEV_COLORS.Suggestion} stopOpacity={0.35} />
420+
<stop offset="95%" stopColor={SEV_COLORS.Suggestion} stopOpacity={0.05} />
421+
</linearGradient>
422+
</defs>
423+
<CartesianGrid {...gridProps} />
424+
<XAxis dataKey="label" tick={axisTick} axisLine={false} tickLine={false} />
425+
<YAxis tick={axisTick} axisLine={false} tickLine={false} />
426+
<Tooltip {...tooltipStyle} />
427+
<Area type="monotone" dataKey="meanHours" stroke={SEV_COLORS.Suggestion} fill="url(#mttrGrad)" strokeWidth={1.5} dot={false} name="Mean hours" />
428+
</AreaChart>
429+
</ResponsiveContainer>
430+
</div>
431+
) : (
432+
<div className="h-32 mt-2 flex items-center justify-center text-center text-text-muted text-sm px-6">
433+
Resolution timing appears once resolved findings include timestamps. Older reviews without tracked resolution times are skipped.
434+
</div>
435+
)}
436+
<div className="flex items-center gap-6 mt-3 pt-3 border-t border-border-subtle">
437+
<div className="text-center">
438+
<div className="text-sm font-bold font-code text-sev-suggestion">{stats.totalFixedFindings}</div>
439+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">FIXED</div>
440+
</div>
441+
<div className="text-center">
442+
<div className="text-sm font-bold font-code text-text-primary">{stats.resolvedWithTimestampCount}</div>
443+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">TIMED</div>
444+
</div>
445+
<div className="text-center">
446+
<div className="text-sm font-bold font-code text-text-primary">{stats.reviewsWithTimedResolutions}</div>
447+
<div className="text-[10px] text-text-muted tracking-[0.05em] font-code">REVIEWS</div>
448+
</div>
449+
</div>
450+
</div>
451+
</div>
452+
347453
<div className="text-[10px] font-semibold text-text-muted tracking-[0.08em] font-code mt-8 mb-3">
348454
LEARNING LOOP
349455
</div>

0 commit comments

Comments
 (0)