diff --git a/frontend/src/components/analytics/CommitHeatmap.tsx b/frontend/src/components/analytics/CommitHeatmap.tsx index 9673459..4fbd42c 100644 --- a/frontend/src/components/analytics/CommitHeatmap.tsx +++ b/frontend/src/components/analytics/CommitHeatmap.tsx @@ -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' @@ -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'] @@ -39,11 +37,30 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit const [dateRange, setDateRange] = useState('1y') const [viewMode, setViewMode] = useState('grid') const [year, setYear] = useState(new Date().getFullYear()) + const gridWrapRef = useRef(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) @@ -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) ?? [] @@ -265,20 +286,20 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit {/* Grid view (default) */} {viewMode === 'grid' && ( <> -
+
{months.map((m, i) => ( - + {m.label} ))} {DAYS_LABELS.map((label, i) => ( label ? ( - + {label} ) : null @@ -286,10 +307,10 @@ export function CommitHeatmap({ data, projects, onFilterChange, title = 'Commit {grid.map((cell, i) => ( -

Project Lifecycle

-
- No commit data yet. -
-
- ) - } - 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()))) @@ -34,13 +29,39 @@ 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(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 ( +
+

Project Lifecycle

+
+ No commit data yet. +
+
+ ) + } + 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) @@ -48,6 +69,21 @@ export function ProjectLifecycle({ data }: Props) { 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 (
@@ -56,23 +92,90 @@ export function ProjectLifecycle({ data }: Props) { {data.length} projects with commit history
-
- + +
+
+ scrollToPct(Number(e.currentTarget.value))} + /> +
+ +
+
+
+ Project +
+ {data.map(project => ( +
+ {project.project_name} +
+ ))} +
+ +
+ {/* Month labels */} {months.map((m, i) => ( - + {m.label} - + ))} {/* 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 @@ -80,10 +183,6 @@ export function ProjectLifecycle({ data }: Props) { return ( - {/* Project name */} - - {project.project_name.length > 22 ? project.project_name.slice(0, 22) + '...' : project.project_name} - {/* Timeline bar */} {/* Monthly activity segments */} @@ -94,7 +193,7 @@ export function ProjectLifecycle({ data }: Props) { return ( ) })} - + +
) diff --git a/frontend/src/components/analytics/StallAlerts.tsx b/frontend/src/components/analytics/StallAlerts.tsx index 37315f8..6585e41 100644 --- a/frontend/src/components/analytics/StallAlerts.tsx +++ b/frontend/src/components/analytics/StallAlerts.tsx @@ -24,12 +24,32 @@ export function StallAlerts({ data }: Props) { return (
-
-

Portfolio Health

-
- - - +
+
+

Portfolio Health

+
+ + + +
+
+
+ Commits: 7d / 30d
diff --git a/frontend/src/index.css b/frontend/src/index.css index 429dee4..ef57c46 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,6 +46,54 @@ body { *:hover::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); } [data-theme="light"] ::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); } +.lifecycle-timeline::-webkit-scrollbar { display: none; } +.lifecycle-range { + width: 100%; + height: 16px; + appearance: none; + -webkit-appearance: none; + background: transparent; + cursor: pointer; +} +.lifecycle-range::-webkit-slider-runnable-track { + height: 8px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + border: 1px solid var(--border2); +} +.lifecycle-range::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 72px; + height: 8px; + margin-top: -1px; + border-radius: 999px; + background: rgba(122,120,146,0.9); + border: 1px solid rgba(255,255,255,0.18); +} +.lifecycle-range::-moz-range-track { + height: 8px; + border-radius: 999px; + background: rgba(255,255,255,0.08); + border: 1px solid var(--border2); +} +.lifecycle-range::-moz-range-thumb { + width: 72px; + height: 8px; + border-radius: 999px; + background: rgba(122,120,146,0.9); + border: 1px solid rgba(255,255,255,0.18); +} +[data-theme="light"] .lifecycle-range::-webkit-slider-runnable-track, +[data-theme="light"] .lifecycle-range::-moz-range-track { + background: rgba(0,0,0,0.07); +} +[data-theme="light"] .lifecycle-range::-webkit-slider-thumb, +[data-theme="light"] .lifecycle-range::-moz-range-thumb { + background: rgba(107,107,128,0.75); + border-color: rgba(0,0,0,0.14); +} + .header-logo { flex-shrink: 0; } /* Keep navbar dark in light mode */