Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions frontend/src/components/analytics/CommitHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { HeatmapDay } from '../../types'
import type { Project } from '../../types'

Expand All @@ -9,9 +9,7 @@ interface Props {
title?: string
}

const CELL_SIZE = 13
const CELL_GAP = 2
const TOTAL = CELL_SIZE + CELL_GAP
const Y_AXIS_WIDTH = 30
const DAYS_LABELS = ['', 'Mon', '', 'Wed', '', 'Fri', '']
const DOW_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']

Expand Down Expand Up @@ -39,11 +37,30 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit
const [dateRange, setDateRange] = useState<DateRange>('1y')
const [viewMode, setViewMode] = useState<ViewMode>('grid')
const [year, setYear] = useState(new Date().getFullYear())
const gridWrapRef = useRef<HTMLDivElement>(null)
const [gridWidth, setGridWidth] = useState(0)
const currentYear = new Date().getFullYear()

const rangeConfig = DATE_RANGES.find(r => r.value === dateRange)!
const canToggleView = dateRange === '4w' || dateRange === '3m'

useEffect(() => {
const el = gridWrapRef.current
if (!el) return

const updateWidth = () => setGridWidth(el.clientWidth)
updateWidth()

if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateWidth)
return () => window.removeEventListener('resize', updateWidth)
}

const observer = new ResizeObserver(updateWidth)
observer.observe(el)
return () => observer.disconnect()
}, [])

function handleProjectChange(projectId: string) {
setSelectedProject(projectId)
onFilterChange?.(projectId || undefined, rangeConfig.days)
Expand Down Expand Up @@ -153,6 +170,10 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit
const totalDays = grid.length
const bestWeek = weekSummaries.reduce((best, w) => w.total > best.total ? w : best, { total: 0, startDate: '', endDate: '', col: 0 })
const maxDaily = dailyData.length > 0 ? Math.max(...dailyData.map(d => d.count), 1) : 1
const gridTotal = Math.max(10, Math.floor((gridWidth - Y_AXIS_WIDTH) / Math.max(weeks, 1)))
const gridGap = Math.max(2, Math.min(4, Math.floor(gridTotal * 0.14)))
const gridCell = Math.max(8, gridTotal - gridGap)
const gridSvgWidth = Math.max(weeks * gridTotal + Y_AXIS_WIDTH, gridWidth)

const projectsWithPath = projects?.filter(p => p.local_path) ?? []

Expand Down Expand Up @@ -265,31 +286,31 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit
{/* Grid view (default) */}
{viewMode === 'grid' && (
<>
<div style={{ overflowX: 'auto', paddingBottom: 4 }}>
<div ref={gridWrapRef} style={{ overflowX: 'hidden', paddingBottom: 4 }}>
<svg
width={weeks * TOTAL + 30}
height={7 * TOTAL + 24}
width={gridSvgWidth}
height={7 * gridTotal + 24}
style={{ display: 'block' }}
>
{months.map((m, i) => (
<text key={i} x={m.col * TOTAL + 30} y={10} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
<text key={i} x={m.col * gridTotal + Y_AXIS_WIDTH} y={10} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
{m.label}
</text>
))}
{DAYS_LABELS.map((label, i) => (
label ? (
<text key={i} x={0} y={i * TOTAL + 24 + 10} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
<text key={i} x={0} y={i * gridTotal + 24 + 10} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
{label}
</text>
) : null
))}
{grid.map((cell, i) => (
<rect
key={i}
x={cell.col * TOTAL + 30}
y={cell.row * TOTAL + 16}
width={CELL_SIZE}
height={CELL_SIZE}
x={cell.col * gridTotal + Y_AXIS_WIDTH}
y={cell.row * gridTotal + 16}
width={gridCell}
height={gridCell}
rx={2}
fill={getColor(cell.count, isDark)}
style={{ transition: 'fill 0.15s' }}
Expand Down
156 changes: 128 additions & 28 deletions frontend/src/components/analytics/ProjectLifecycle.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,18 @@
import React, { useMemo } from 'react'
import React, { useMemo, useRef, useEffect, useState } from 'react'
import type { LifecycleItem } from '../../types'

interface Props {
data: LifecycleItem[]
}

export function ProjectLifecycle({ data }: Props) {
if (data.length === 0) {
return (
<div>
<h3 style={{ fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Project Lifecycle</h3>
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>
No commit data yet.
</div>
</div>
)
}

const { months, minDate, monthCount, barWidth } = useMemo(() => {
if (data.length === 0) {
const now = new Date()
now.setDate(1)
return { months: [], minDate: now, monthCount: 0, barWidth: 720 }
}

const allDates = data.flatMap(p => [new Date(p.first_commit), new Date(p.last_commit)])
const minDate = new Date(Math.min(...allDates.map(d => d.getTime())))
const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())))
Expand All @@ -34,20 +29,61 @@ export function ProjectLifecycle({ data }: Props) {
})
}

return { months, minDate, monthCount, barWidth: Math.max(600, monthCount * 60) }
return { months, minDate, monthCount, barWidth: Math.max(900, monthCount * 64) }
}, [data])

const scrollRef = useRef<HTMLDivElement>(null)
const [scrollPct, setScrollPct] = useState(1000)

useEffect(() => {
const frame = requestAnimationFrame(() => {
const el = scrollRef.current
if (!el) return
const maxScroll = el.scrollWidth - el.clientWidth
el.scrollLeft = maxScroll
setScrollPct(maxScroll > 0 ? 1000 : 0)
})
return () => cancelAnimationFrame(frame)
}, [data])

if (data.length === 0) {
return (
<div>
<h3 style={{ fontSize: 13, fontWeight: 700, marginBottom: 12 }}>Project Lifecycle</h3>
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--muted)', fontSize: 12 }}>
No commit data yet.
</div>
</div>
)
}

const ROW_HEIGHT = 32
const LEFT = 180
const LABEL_WIDTH = 180
const HEADER_HEIGHT = 26
const chartWidth = barWidth
const colWidth = (chartWidth - LEFT) / Math.max(monthCount, 1)
const colWidth = chartWidth / Math.max(monthCount, 1)

function monthOffset(dateStr: string): number {
const d = new Date(dateStr)
return (d.getFullYear() - minDate.getFullYear()) * 12 + d.getMonth() - minDate.getMonth() +
d.getDate() / 30
}

function scrollToPct(value: number) {
setScrollPct(value)
const el = scrollRef.current
if (!el) return
const maxScroll = el.scrollWidth - el.clientWidth
el.scrollLeft = maxScroll * (value / 1000)
}

function handleTimelineScroll() {
const el = scrollRef.current
if (!el) return
const maxScroll = el.scrollWidth - el.clientWidth
setScrollPct(maxScroll > 0 ? Math.round((el.scrollLeft / maxScroll) * 1000) : 0)
}

return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
Expand All @@ -56,34 +92,97 @@ export function ProjectLifecycle({ data }: Props) {
{data.length} projects with commit history
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<svg width={chartWidth} height={data.length * ROW_HEIGHT + 30} style={{ display: 'block' }}>

<div style={{
display: 'grid',
gridTemplateColumns: `${LABEL_WIDTH}px minmax(0, 1fr)`,
alignItems: 'start',
marginBottom: 8,
}}>
<div />
<input
className="lifecycle-range"
type="range"
aria-label="Scroll project lifecycle timeline"
min={0}
max={1000}
value={scrollPct}
onChange={e => scrollToPct(Number(e.currentTarget.value))}
/>
</div>

<div style={{
display: 'grid',
gridTemplateColumns: `${LABEL_WIDTH}px minmax(0, 1fr)`,
alignItems: 'start',
}}>
<div style={{
position: 'relative',
zIndex: 1,
background: 'var(--surface)',
boxShadow: '12px 0 18px -18px rgba(0,0,0,0.8)',
}}>
<div style={{
height: HEADER_HEIGHT,
display: 'flex',
alignItems: 'center',
fontSize: 9,
fontFamily: 'var(--mono)',
color: 'var(--muted)',
}}>
Project
</div>
{data.map(project => (
<div key={project.project_id} style={{
height: ROW_HEIGHT,
display: 'flex',
alignItems: 'center',
paddingRight: 12,
fontSize: 11,
fontWeight: 500,
color: 'var(--text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}} title={project.project_name}>
{project.project_name}
</div>
))}
</div>

<div
ref={scrollRef}
className="lifecycle-timeline"
onScroll={handleTimelineScroll}
style={{
overflowX: 'auto',
overflowY: 'hidden',
scrollbarWidth: 'none',
}}
>
<svg width={chartWidth + 56} height={data.length * ROW_HEIGHT + HEADER_HEIGHT} style={{ display: 'block' }}>
{/* Month labels */}
{months.map((m, i) => (
<g key={i}>
<text x={LEFT + i * colWidth} y={12} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
<text x={i * colWidth} y={12} fill="var(--muted)" fontSize={9} fontFamily="var(--mono)">
{m.label}
</text>
<line x1={LEFT + i * colWidth} y1={18} x2={LEFT + i * colWidth} y2={data.length * ROW_HEIGHT + 20} stroke="var(--border)" strokeDasharray="2 4" />
<line x1={i * colWidth} y1={18} x2={i * colWidth} y2={data.length * ROW_HEIGHT + 20} stroke="var(--border)" strokeDasharray="2 4" />
</g>
))}

{/* Project rows */}
{data.map((project, idx) => {
const y = idx * ROW_HEIGHT + 26
const startX = LEFT + monthOffset(project.first_commit) * colWidth
const endX = LEFT + monthOffset(project.last_commit) * colWidth
const y = idx * ROW_HEIGHT + HEADER_HEIGHT
const startX = monthOffset(project.first_commit) * colWidth
const endX = monthOffset(project.last_commit) * colWidth
const barLen = Math.max(endX - startX, 4)

// Activity intensity from monthly data
const maxMonthly = Math.max(...project.monthly_activity.map(m => m.commits), 1)

return (
<g key={project.project_id}>
{/* Project name */}
<text x={LEFT - 8} y={y + 12} fill="var(--text)" fontSize={11} textAnchor="end" fontWeight={500}>
{project.project_name.length > 22 ? project.project_name.slice(0, 22) + '...' : project.project_name}
</text>
{/* Timeline bar */}
<rect x={startX} y={y + 2} width={barLen} height={16} rx={3} fill="rgba(37,99,235,0.15)" />
{/* Monthly activity segments */}
Expand All @@ -94,7 +193,7 @@ export function ProjectLifecycle({ data }: Props) {
return (
<rect
key={mi}
x={LEFT + mOffset * colWidth}
x={mOffset * colWidth}
y={y + 2}
width={Math.max(colWidth - 1, 2)}
height={16}
Expand All @@ -112,7 +211,8 @@ export function ProjectLifecycle({ data }: Props) {
</g>
)
})}
</svg>
</svg>
</div>
</div>
</div>
)
Expand Down
32 changes: 26 additions & 6 deletions frontend/src/components/analytics/StallAlerts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,32 @@ export function StallAlerts({ data }: Props) {

return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
<h3 style={{ fontSize: 13, fontWeight: 700 }}>Portfolio Health</h3>
<div style={{ display: 'flex', gap: 10 }}>
<HealthBadge count={active} status="active" />
<HealthBadge count={coolingCount} status="cooling" />
<HealthBadge count={dormantCount} status="dormant" />
<div style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16,
marginBottom: 12,
flexWrap: 'wrap',
}}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ fontSize: 13, fontWeight: 700 }}>Portfolio Health</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<HealthBadge count={active} status="active" />
<HealthBadge count={coolingCount} status="cooling" />
<HealthBadge count={dormantCount} status="dormant" />
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
minHeight: 20,
fontSize: 10,
color: 'var(--muted)',
fontFamily: 'var(--mono)',
}}>
<span>Commits: 7d / 30d</span>
</div>
</div>

Expand Down
Loading