Files
fabdash/frontend/src/components/Calendar/WorkloadHeatmap.jsx
2026-03-06 00:28:33 -06:00

338 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) }
}
function DensityLegend({ statusKey }) {
const c = STATUS_CELL_COLORS[statusKey]
return (
<div className="flex items-center gap-1 text-[9px] text-text-muted/35 select-none">
<span>Less</span>
<div className="w-2 h-2 rounded-sm border bg-surface border-surface-border" />
<div className={`w-2 h-2 rounded-sm border ${c[0]}`} />
<div className={`w-2 h-2 rounded-sm border ${c[1]}`} />
<div className={`w-2 h-2 rounded-sm border ${c[2]}`} />
<span>More</span>
</div>
)
}
export default function WorkloadHeatmap() {
const projects = useProjectStore(s => s.projects)
const [tooltip, setTooltip] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [weekOffset, setWeekOffset] = useState(0)
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')} ${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 (
<div className="flex flex-col h-full bg-surface overflow-auto">
{/* Header + window navigation */}
<div className="flex items-center justify-between pl-24 pr-8 pt-6 pb-3">
<div>
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2>
<p className="text-text-muted text-xs mt-0.5">20 weeks of deliverable density by status</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setWeekOffset(o => o - 4)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-surface-border bg-surface-elevated text-text-muted hover:text-gold hover:border-gold/40 transition-all"
title="Shift window back 4 weeks"
>&larr; 4 wks</button>
<button
onClick={() => setWeekOffset(0)}
className={`text-xs px-2.5 py-1.5 rounded-lg border transition-all font-medium ${
weekOffset === 0
? 'border-gold/50 text-gold bg-gold/10'
: 'border-surface-border bg-surface-elevated text-text-muted hover:text-gold hover:border-gold/40'
}`}
title="Center window on today"
>Today</button>
<button
onClick={() => setWeekOffset(o => o + 4)}
className="text-xs px-2.5 py-1.5 rounded-lg border border-surface-border bg-surface-elevated text-text-muted hover:text-gold hover:border-gold/40 transition-all"
title="Shift window forward 4 weeks"
>4 wks &rarr;</button>
<span className="text-[10px] text-text-muted/40 font-mono ml-1 hidden xl:block">{windowLabel}</span>
</div>
</div>
{/* Stat cards + per-status heatmaps */}
<div className="grid grid-cols-4 gap-4 px-8 pb-4">
{STATUS_KEYS.map((statusKey) => (
<div key={statusKey} className="flex flex-col gap-3">
<div className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center">
<p className={`text-2xl font-bold ${STATUS_COLOR[statusKey]}`}>{stats[statusKey]}</p>
<p className="text-text-muted text-xs mt-1">{STATUS_LABEL[statusKey]}</p>
</div>
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-3 flex-1 min-h-[160px] flex flex-col gap-2">
<div className="flex justify-end">
<DensityLegend statusKey={statusKey} />
</div>
<div className="flex items-center justify-center flex-1">
<div className="flex gap-2 overflow-x-auto pb-1 justify-center">
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
{DAY_INIT.map((d, i) => (
<div key={i} style={{ height: CELL }} className="flex items-center text-[9px] text-text-muted/50 font-mono w-3">{d}</div>
))}
</div>
<div className="flex flex-col flex-shrink-0">
<div className="relative h-4 mb-1">
{monthLabels.map(({ wi, label }) => (
<span key={label+wi} className="absolute text-[9px] text-text-muted/60 font-medium" style={{ left: wi * (CELL + GAP) }}>{label}</span>
))}
</div>
<div className="flex" style={{ gap: GAP }}>
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
{week.map(({ date, key, items, statusCounts }) => {
const count = (statusCounts || {})[statusKey] || 0
return (
<div
key={key + statusKey}
style={{ width: CELL, height: CELL }}
className={`rounded-sm border cursor-pointer transition-transform duration-100 hover:scale-125 hover:z-10
${getCellClass(count, statusKey)}
${isToday(date) ? 'ring-1 ring-white/60' : ''}
${count > 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)}
/>
)
})}
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Combined All Tasks heatmap */}
<div className="px-8 pb-8">
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-baseline gap-2">
<h3 className="text-xs font-semibold tracking-widest uppercase text-text-muted/70">All Tasks</h3>
<span className="text-text-muted/60 text-[10px] font-mono">{stats.total} tasks</span>
</div>
<div className="flex items-center gap-4 text-[9px] text-text-muted/60">
{STATUS_KEYS.map(sk => (
<div key={sk} className="flex items-center gap-1.5">
<div className={`w-2.5 h-2.5 rounded-sm border ${STATUS_CELL_COLORS[sk][2].split(' ').slice(0,2).join(' ')}`} />
<span className={STATUS_COLOR[sk]}>{STATUS_LABEL[sk]}</span>
</div>
))}
<span className="ml-1 text-text-muted/40">&middot; color = highest priority</span>
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 justify-center">
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
{DAY_INIT.map((d, i) => (
<div key={i} style={{ height: CELL_LG }} className="flex items-center text-[11px] text-text-muted/50 font-mono w-4">{d}</div>
))}
</div>
<div className="flex flex-col flex-shrink-0">
<div className="relative h-5 mb-1">
{monthLabelsBig.map(({ wi, label }) => (
<span key={label+wi} className="absolute text-[10px] text-text-muted/60 font-medium" style={{ left: wi * (CELL_LG + GAP_LG) }}>{label}</span>
))}
</div>
<div className="flex" style={{ gap: GAP_LG }}>
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP_LG }}>
{week.map(({ date, key, items, statusCounts }) => {
const { dominant, total } = getDominantStatus(statusCounts || {})
return (
<div
key={key + 'combined'}
style={{ width: CELL_LG, height: CELL_LG }}
className={`rounded-sm border cursor-pointer transition-transform duration-100 hover:scale-125 hover:z-10
${dominant ? getCellClass(total, dominant) : 'bg-surface border-surface-border'}
${isToday(date) ? 'ring-1 ring-white/60' : ''}
${dominant ? STATUS_HOVER_RING[dominant] : ''}
`}
onClick={() => 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)}
/>
)
})}
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Hover tooltip */}
{tooltip && (
<div
className="fixed z-[200] pointer-events-none bg-surface-elevated border border-surface-border rounded-xl shadow-2xl px-3.5 py-3 min-w-[220px] max-w-[320px]"
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 330), top: Math.max(tooltip.y - 100, 8) }}
>
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
{isToday(tooltip.date) ? 'Today — ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
</p>
{tooltip.combined && tooltip.statusCounts ? (
<div className="space-y-1 mb-2">
{STATUS_KEYS.filter(sk => (tooltip.statusCounts[sk] || 0) > 0).map(sk => (
<div key={sk} className="flex items-center justify-between gap-3">
<span className={`text-[10px] ${STATUS_COLOR[sk]}`}>{STATUS_LABEL[sk]}</span>
<span className="text-[10px] text-text-muted/60 font-mono">{tooltip.statusCounts[sk]}</span>
</div>
))}
</div>
) : (
<p className={`text-[10px] mb-1.5 ${tooltip.statusKey ? STATUS_COLOR[tooltip.statusKey] : 'text-text-muted/60'}`}>
{tooltip.statusKey ? STATUS_LABEL[tooltip.statusKey] : ''} &middot; {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''}
</p>
)}
<div className="space-y-1.5">
{(tooltip.items || []).slice(0, 5).map(({ deliverable, project }) => (
<div key={deliverable.id} className="flex items-start gap-1.5">
<div className="w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: project.color }} />
<div className="min-w-0 flex-1">
<p className="text-[11px] text-text-primary truncate">{deliverable.title}</p>
<p className="text-[10px] text-text-muted/60">{project.name}</p>
</div>
</div>
))}
{(tooltip.items || []).length > 5 && (
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
)}
</div>
<p className="text-[9px] text-text-muted/25 mt-2 pt-1.5 border-t border-surface-border/50">Click to open day detail</p>
</div>
)}
{/* Day detail panel */}
{selectedDay && (
<HeatmapDayPanel
date={selectedDay}
onClose={() => setSelectedDay(null)}
/>
)}
</div>
)
}