2026-03-05 15:39:21 -06:00
|
|
|
import { useMemo, useState } from 'react'
|
2026-03-05 17:05:47 -06:00
|
|
|
import { format, startOfWeek, addDays, addWeeks, parseISO, isToday } from 'date-fns'
|
2026-03-05 15:39:21 -06:00
|
|
|
import useProjectStore from '../../store/useProjectStore'
|
|
|
|
|
import useFocusStore from '../../store/useFocusStore'
|
|
|
|
|
|
2026-03-05 17:05:47 -06:00
|
|
|
const WEEKS = 20
|
|
|
|
|
const DAY_INIT = ['M','T','W','T','F','S','S']
|
2026-03-05 15:39:21 -06:00
|
|
|
|
2026-03-05 17:05:47 -06:00
|
|
|
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',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCellClass(baseDensity, statusCounts) {
|
|
|
|
|
const total = Object.values(statusCounts).reduce((a, b) => a + b, 0)
|
|
|
|
|
if (total === 0) return 'bg-surface border-surface-border'
|
|
|
|
|
if (baseDensity === 1) return 'bg-gold/25 border-gold/40'
|
|
|
|
|
if (baseDensity === 2) return 'bg-gold/55 border-gold/70'
|
2026-03-05 15:39:21 -06:00
|
|
|
return 'bg-gold border-gold shadow-gold'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function WorkloadHeatmap() {
|
|
|
|
|
const projects = useProjectStore(s => s.projects)
|
|
|
|
|
const openFocus = useFocusStore(s => s.openFocus)
|
|
|
|
|
const [tooltip, setTooltip] = useState(null)
|
|
|
|
|
|
|
|
|
|
const { weeks, stats } = useMemo(() => {
|
|
|
|
|
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
|
|
|
|
|
const map = {}
|
2026-03-05 17:05:47 -06:00
|
|
|
|
2026-03-05 15:39:21 -06:00
|
|
|
projects.forEach(p => {
|
|
|
|
|
(p.deliverables || []).forEach(d => {
|
2026-03-05 17:05:47 -06:00
|
|
|
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]++
|
2026-03-05 15:39:21 -06:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
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')
|
2026-03-05 17:05:47 -06:00
|
|
|
const entry = map[key] || { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
|
|
|
|
|
return { date, key, ...entry }
|
2026-03-05 15:39:21 -06:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const all = projects.flatMap(p => p.deliverables || [])
|
|
|
|
|
const stats = {
|
|
|
|
|
total: all.length,
|
2026-03-05 17:05:47 -06:00
|
|
|
upcoming: all.filter(d => d.status === 'upcoming').length,
|
2026-03-05 15:39:21 -06:00
|
|
|
in_progress: all.filter(d => d.status === 'in_progress').length,
|
|
|
|
|
completed: all.filter(d => d.status === 'completed').length,
|
2026-03-05 17:05:47 -06:00
|
|
|
overdue: all.filter(d => d.status === 'overdue').length,
|
2026-03-05 15:39:21 -06:00
|
|
|
}
|
|
|
|
|
return { weeks: grid, stats }
|
|
|
|
|
}, [projects])
|
|
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
2026-03-05 17:10:30 -06:00
|
|
|
const CELL = 16
|
|
|
|
|
const GAP = 2
|
2026-03-05 15:39:21 -06:00
|
|
|
|
|
|
|
|
return (
|
2026-03-05 17:05:47 -06:00
|
|
|
<div className="flex flex-col h-full bg-surface overflow-auto">
|
|
|
|
|
{/* Header with spacing that clears FABDASH corner logo */}
|
|
|
|
|
<div className="flex items-center justify-between px-8 pt-6 pb-4">
|
2026-03-05 15:39:21 -06:00
|
|
|
<div>
|
|
|
|
|
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2>
|
2026-03-05 17:05:47 -06:00
|
|
|
<p className="text-text-muted text-xs mt-0.5">20 weeks of deliverable density by status</p>
|
2026-03-05 15:39:21 -06:00
|
|
|
</div>
|
2026-03-05 17:05:47 -06:00
|
|
|
<div className="flex items-center gap-3 text-[10px] text-text-muted">
|
|
|
|
|
<span className="uppercase tracking-[0.18em] text-text-muted/60">FABDASH</span>
|
2026-03-05 15:39:21 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-05 17:10:30 -06:00
|
|
|
{/* Stat cards + aligned status heatmaps */}
|
|
|
|
|
<div className="grid grid-cols-4 gap-4 px-8 pb-8">
|
|
|
|
|
{STATUS_KEYS.map((statusKey) => (
|
|
|
|
|
<div key={statusKey} className="flex flex-col gap-3">
|
|
|
|
|
{/* Stat card */}
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* Heatmap for this status */}
|
|
|
|
|
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-3 flex-1 min-h-[160px]">
|
|
|
|
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
|
|
|
{/* Day labels */}
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
{/* Grid */}
|
|
|
|
|
<div className="flex flex-col flex-shrink-0">
|
|
|
|
|
{/* Month labels */}
|
|
|
|
|
<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 countForStatus = (statusCounts || {})[statusKey] || 0
|
|
|
|
|
const baseDensity = countForStatus
|
|
|
|
|
const hoverRing =
|
|
|
|
|
statusKey === 'upcoming' ? 'hover:ring-blue-300/80 hover:shadow-[0_0_0_1px_rgba(147,197,253,0.8)]' :
|
|
|
|
|
statusKey === 'in_progress' ? 'hover:ring-amber-300/80 hover:shadow-[0_0_0_1px_rgba(252,211,77,0.8)]' :
|
|
|
|
|
statusKey === 'completed' ? 'hover:ring-green-300/80 hover:shadow-[0_0_0_1px_rgba(74,222,128,0.8)]' :
|
|
|
|
|
statusKey === 'overdue' ? 'hover:ring-red-400/90 hover:shadow-[0_0_0_1px_rgba(248,113,113,0.9)]' :
|
|
|
|
|
'hover:ring-white/60';
|
|
|
|
|
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 relative
|
|
|
|
|
${getCellClass(baseDensity, statusCounts || {})}
|
|
|
|
|
${isToday(date) ? 'ring-1 ring-white/60' : ''}
|
|
|
|
|
${hoverRing}
|
|
|
|
|
`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!items || !items.length) return
|
|
|
|
|
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0]
|
|
|
|
|
if (match) openFocus(match.project.id, match.deliverable.id)
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
|
|
|
|
|
const showItems = filtered.length ? filtered : items || []
|
|
|
|
|
setTooltip({
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
y: e.clientY,
|
|
|
|
|
date,
|
|
|
|
|
statusKey,
|
|
|
|
|
items: showItems,
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={() => setTooltip(null)}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-05 15:39:21 -06:00
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-03-05 17:05:47 -06:00
|
|
|
{/* Multi-row heatmaps by status */}
|
|
|
|
|
<div className="flex flex-col gap-6 px-8 pb-8">
|
|
|
|
|
{STATUS_KEYS.map((statusKey) => (
|
|
|
|
|
<div key={statusKey} className="bg-surface-elevated/40 border border-surface-border rounded-xl p-4">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
<div className="flex items-baseline gap-2">
|
|
|
|
|
<h3 className="text-xs font-semibold tracking-widest uppercase text-text-muted/70">
|
|
|
|
|
{STATUS_LABEL[statusKey]}
|
|
|
|
|
</h3>
|
|
|
|
|
<span className={`${STATUS_COLOR[statusKey]} text-[10px] font-mono`}>
|
|
|
|
|
{stats[statusKey]} tasks
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2 text-[9px] text-text-muted/60">
|
|
|
|
|
<span>Less</span>
|
|
|
|
|
{['bg-surface border-surface-border','bg-gold/25 border-gold/40','bg-gold/55 border-gold/70','bg-gold border-gold'].map((c,i) => (
|
|
|
|
|
<div key={i} className={`w-3 h-3 rounded-sm border ${c}`} />
|
|
|
|
|
))}
|
|
|
|
|
<span>More</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-05 15:39:21 -06:00
|
|
|
|
2026-03-05 17:05:47 -06:00
|
|
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
|
|
|
|
{/* Day labels */}
|
|
|
|
|
<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-[10px] text-text-muted/50 font-mono w-4">
|
|
|
|
|
{d}
|
|
|
|
|
</div>
|
2026-03-05 15:39:21 -06:00
|
|
|
))}
|
|
|
|
|
</div>
|
2026-03-05 17:05:47 -06:00
|
|
|
|
|
|
|
|
{/* Grid */}
|
|
|
|
|
<div className="flex flex-col flex-shrink-0">
|
|
|
|
|
{/* Month labels */}
|
|
|
|
|
<div className="relative h-5 mb-1">
|
|
|
|
|
{monthLabels.map(({ wi, label }) => (
|
|
|
|
|
<span
|
|
|
|
|
key={label+wi}
|
|
|
|
|
className="absolute text-[10px] 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 countForStatus = (statusCounts || {})[statusKey] || 0
|
|
|
|
|
const baseDensity = countForStatus
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={key + statusKey}
|
|
|
|
|
style={{ width: CELL, height: CELL }}
|
|
|
|
|
className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative
|
|
|
|
|
${getCellClass(baseDensity, statusCounts || {})}
|
|
|
|
|
${isToday(date) ? 'ring-1 ring-white/40' : ''}
|
|
|
|
|
`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!items || !items.length) return
|
|
|
|
|
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0]
|
|
|
|
|
if (match) openFocus(match.project.id, match.deliverable.id)
|
|
|
|
|
}}
|
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
|
|
|
|
|
const showItems = filtered.length ? filtered : items || []
|
|
|
|
|
setTooltip({
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
y: e.clientY,
|
|
|
|
|
date,
|
|
|
|
|
statusKey,
|
|
|
|
|
items: showItems,
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
onMouseLeave={() => setTooltip(null)}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-05 15:39:21 -06:00
|
|
|
</div>
|
2026-03-05 17:05:47 -06:00
|
|
|
))}
|
2026-03-05 15:39:21 -06:00
|
|
|
|
|
|
|
|
{tooltip && (
|
2026-03-05 17:05:47 -06:00
|
|
|
<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) }}
|
|
|
|
|
>
|
2026-03-05 15:39:21 -06:00
|
|
|
<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>
|
2026-03-05 17:05:47 -06:00
|
|
|
<p className="text-[10px] text-text-muted/60 mb-1.5">
|
|
|
|
|
{STATUS_LABEL[tooltip.statusKey]} · {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''}
|
|
|
|
|
</p>
|
2026-03-05 15:39:21 -06:00
|
|
|
{tooltip.items.length === 0 ? (
|
|
|
|
|
<p className="text-text-muted/50 text-xs">No deliverables</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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-05 17:05:47 -06:00
|
|
|
</div>
|
2026-03-05 15:39:21 -06:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|