import { useMemo, useState } from 'react'
import { format, startOfWeek, addDays, addWeeks, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore'
import HeatmapDayPanel from './HeatmapDayPanel'
const WEEKS = 20
const DAY_INIT = ['M','T','W','T','F','S','S']
const CELL = 16
const CELL_LG = 40
const GAP = 2
const GAP_LG = 4
const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
const STATUS_LABEL = { upcoming: 'Upcoming', in_progress: 'In Progress', completed: 'Completed', overdue: 'Overdue' }
const STATUS_COLOR = { upcoming: 'text-blue-400', in_progress: 'text-amber-400', completed: 'text-green-400', overdue: 'text-red-400' }
const STATUS_CELL_COLORS = {
upcoming: ['bg-blue-400/20 border-blue-400/30', 'bg-blue-400/55 border-blue-400/70', 'bg-blue-400 border-blue-400 shadow-[0_0_4px_rgba(96,165,250,0.6)]'],
in_progress: ['bg-amber-400/20 border-amber-400/30','bg-amber-400/55 border-amber-400/70','bg-amber-400 border-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.6)]'],
completed: ['bg-green-400/20 border-green-400/30','bg-green-400/55 border-green-400/70','bg-green-400 border-green-400 shadow-[0_0_4px_rgba(74,222,128,0.6)]'],
overdue: ['bg-red-400/20 border-red-400/30', 'bg-red-400/55 border-red-400/70', 'bg-red-400 border-red-400 shadow-[0_0_4px_rgba(248,113,113,0.6)]'],
}
const STATUS_HOVER_RING = {
upcoming: 'hover:ring-1 hover:ring-blue-300/80',
in_progress: 'hover:ring-1 hover:ring-amber-300/80',
completed: 'hover:ring-1 hover:ring-green-300/80',
overdue: 'hover:ring-1 hover:ring-red-400/90',
}
const STATUS_PRIORITY = { overdue: 4, in_progress: 3, upcoming: 2, completed: 1 }
function getCellClass(count, statusKey) {
if (count === 0) return 'bg-surface border-surface-border'
const c = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
if (count === 1) return c[0]
if (count === 2) return c[1]
return c[2]
}
function getDominantStatus(statusCounts) {
let dominant = null, maxCount = 0, maxPriority = 0
for (const [sk, count] of Object.entries(statusCounts)) {
if (count === 0) continue
if (count > maxCount || (count === maxCount && (STATUS_PRIORITY[sk] || 0) > maxPriority)) {
dominant = sk; maxCount = count; maxPriority = STATUS_PRIORITY[sk] || 0
}
}
return { dominant, total: Object.values(statusCounts).reduce((a, b) => a + b, 0) }
}
// 4-cell density legend strip
function DensityLegend({ statusKey }) {
const c = STATUS_CELL_COLORS[statusKey]
return (
)
}
export default function WorkloadHeatmap() {
const projects = useProjectStore(s => s.projects)
const [tooltip, setTooltip] = useState(null)
const [selectedDay, setSelectedDay] = useState(null) // Date | null
const [weekOffset, setWeekOffset] = useState(0) // shifts window in weeks
const { weeks, stats } = useMemo(() => {
const start = startOfWeek(addWeeks(new Date(), -10 + weekOffset), { weekStartsOn: 1 })
const map = {}
projects.forEach(p => {
;(p.deliverables || []).forEach(d => {
const key = d.due_date
if (!map[key]) map[key] = { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
map[key].items.push({ deliverable: d, project: p })
const s = d.status || 'upcoming'
if (map[key].statusCounts[s] !== undefined) map[key].statusCounts[s]++
})
})
const grid = Array.from({ length: WEEKS }, (_, wi) =>
Array.from({ length: 7 }, (_, di) => {
const date = addDays(start, wi * 7 + di)
const key = format(date, 'yyyy-MM-dd')
const entry = map[key] || { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
return { date, key, ...entry }
})
)
const all = projects.flatMap(p => p.deliverables || [])
const stats = {
total: all.length,
upcoming: all.filter(d => d.status === 'upcoming').length,
in_progress: all.filter(d => d.status === 'in_progress').length,
completed: all.filter(d => d.status === 'completed').length,
overdue: all.filter(d => d.status === 'overdue').length,
}
return { weeks: grid, stats }
}, [projects, weekOffset])
const windowStart = weeks[0]?.[0]?.date
const windowEnd = weeks[WEEKS - 1]?.[6]?.date
const windowLabel = windowStart && windowEnd
? `${format(windowStart, 'MMM d')} \u2013 ${format(windowEnd, 'MMM d, yyyy')}`
: ''
const monthLabels = useMemo(() => {
const labels = []; let last = -1
weeks.forEach((week, wi) => {
const m = week[0].date.getMonth()
if (m !== last) { labels.push({ wi, label: format(week[0].date, 'MMM') }); last = m }
})
return labels
}, [weeks])
const monthLabelsBig = useMemo(() => {
const labels = []; let last = -1
weeks.forEach((week, wi) => {
const m = week[0].date.getMonth()
if (m !== last) { labels.push({ wi, label: format(week[0].date, 'MMM') }); last = m }
})
return labels
}, [weeks])
return (
{/* Header */}
Workload Heatmap
20 weeks of deliverable density by status
{/* Window navigation */}
{windowLabel}
{/* Stat cards + per-status heatmaps */}
{STATUS_KEYS.map((statusKey) => (
{/* Stat card */}
{stats[statusKey]}
{STATUS_LABEL[statusKey]}
{/* Mini heatmap */}
{/* Day initials */}
{DAY_INIT.map((d, i) => (
{d}
))}
{/* Grid */}
{monthLabels.map(({ wi, label }) => (
{label}
))}
{weeks.map((week, wi) => (
{week.map(({ date, key, items, statusCounts }) => {
const count = (statusCounts || {})[statusKey] || 0
return (
0 ? STATUS_HOVER_RING[statusKey] : ''}
`}
onClick={() => setSelectedDay(date)}
onMouseEnter={(e) => {
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
if (!filtered.length) return
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey, items: filtered })
}}
onMouseLeave={() => setTooltip(null)}
/>
)
})}
))}
))}
{/* Combined All Tasks heatmap */}
All Tasks
{stats.total} tasks
{STATUS_KEYS.map(sk => (
))}
\u00b7 color = highest priority
{DAY_INIT.map((d, i) => (
{d}
))}
{monthLabelsBig.map(({ wi, label }) => (
{label}
))}
{weeks.map((week, wi) => (
{week.map(({ date, key, items, statusCounts }) => {
const { dominant, total } = getDominantStatus(statusCounts || {})
return (
setSelectedDay(date)}
onMouseEnter={(e) => {
if (!items?.length) return
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey: dominant, items, combined: true, statusCounts })
}}
onMouseLeave={() => setTooltip(null)}
/>
)
})}
))}
{/* Hover tooltip */}
{tooltip && (
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
{tooltip.combined && tooltip.statusCounts ? (
{STATUS_KEYS.filter(sk => (tooltip.statusCounts[sk] || 0) > 0).map(sk => (
{STATUS_LABEL[sk]}
{tooltip.statusCounts[sk]}
))}
) : (
{tooltip.statusKey ? STATUS_LABEL[tooltip.statusKey] : ''} \u00b7 {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''}
)}
{(tooltip.items || []).slice(0, 5).map(({ deliverable, project }) => (
{deliverable.title}
{project.name}
))}
{(tooltip.items || []).length > 5 && (
+{tooltip.items.length - 5} more
)}
Click to open day detail
)}
{/* Day detail panel */}
{selectedDay && (
setSelectedDay(null)}
/>
)}
)
}