Merge pull request #29 from jasonMPM/large-heatmap
Add files via upload
This commit is contained in:
@@ -5,6 +5,9 @@ import useFocusStore from '../../store/useFocusStore'
|
|||||||
|
|
||||||
const WEEKS = 20
|
const WEEKS = 20
|
||||||
const DAY_INIT = ['M','T','W','T','F','S','S']
|
const DAY_INIT = ['M','T','W','T','F','S','S']
|
||||||
|
const CELL = 16
|
||||||
|
const CELL_LG = 20
|
||||||
|
const GAP = 2
|
||||||
|
|
||||||
const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
|
const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
|
||||||
const STATUS_LABEL = {
|
const STATUS_LABEL = {
|
||||||
@@ -20,7 +23,6 @@ const STATUS_COLOR = {
|
|||||||
overdue: 'text-red-400',
|
overdue: 'text-red-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cell colors per status, three density levels: low / mid / high
|
|
||||||
const STATUS_CELL_COLORS = {
|
const STATUS_CELL_COLORS = {
|
||||||
upcoming: [
|
upcoming: [
|
||||||
'bg-blue-400/20 border-blue-400/30',
|
'bg-blue-400/20 border-blue-400/30',
|
||||||
@@ -51,6 +53,9 @@ const STATUS_HOVER_RING = {
|
|||||||
overdue: 'hover:ring-1 hover:ring-red-400/90',
|
overdue: 'hover:ring-1 hover:ring-red-400/90',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tie-break priority: overdue > in_progress > upcoming > completed
|
||||||
|
const STATUS_PRIORITY = { overdue: 4, in_progress: 3, upcoming: 2, completed: 1 }
|
||||||
|
|
||||||
function getCellClass(count, statusKey) {
|
function getCellClass(count, statusKey) {
|
||||||
if (count === 0) return 'bg-surface border-surface-border'
|
if (count === 0) return 'bg-surface border-surface-border'
|
||||||
const colors = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
|
const colors = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
|
||||||
@@ -59,6 +64,24 @@ function getCellClass(count, statusKey) {
|
|||||||
return colors[2]
|
return colors[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDominantStatus(statusCounts) {
|
||||||
|
let dominant = null
|
||||||
|
let maxCount = 0
|
||||||
|
let 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) }
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkloadHeatmap() {
|
export default function WorkloadHeatmap() {
|
||||||
const projects = useProjectStore(s => s.projects)
|
const projects = useProjectStore(s => s.projects)
|
||||||
const openFocus = useFocusStore(s => s.openFocus)
|
const openFocus = useFocusStore(s => s.openFocus)
|
||||||
@@ -80,14 +103,14 @@ export default function WorkloadHeatmap() {
|
|||||||
|
|
||||||
const grid = Array.from({ length: WEEKS }, (_, wi) =>
|
const grid = Array.from({ length: WEEKS }, (_, wi) =>
|
||||||
Array.from({ length: 7 }, (_, di) => {
|
Array.from({ length: 7 }, (_, di) => {
|
||||||
const date = addDays(start, wi * 7 + di)
|
const date = addDays(start, wi * 7 + di)
|
||||||
const key = format(date, 'yyyy-MM-dd')
|
const key = format(date, 'yyyy-MM-dd')
|
||||||
const entry = map[key] || { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
|
const entry = map[key] || { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
|
||||||
return { date, key, ...entry }
|
return { date, key, ...entry }
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const all = projects.flatMap(p => p.deliverables || [])
|
const all = projects.flatMap(p => p.deliverables || [])
|
||||||
const stats = {
|
const stats = {
|
||||||
total: all.length,
|
total: all.length,
|
||||||
upcoming: all.filter(d => d.status === 'upcoming').length,
|
upcoming: all.filter(d => d.status === 'upcoming').length,
|
||||||
@@ -107,8 +130,14 @@ export default function WorkloadHeatmap() {
|
|||||||
return labels
|
return labels
|
||||||
}, [weeks])
|
}, [weeks])
|
||||||
|
|
||||||
const CELL = 16
|
const monthLabelsBig = useMemo(() => {
|
||||||
const GAP = 2
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full bg-surface overflow-auto">
|
<div className="flex flex-col h-full bg-surface overflow-auto">
|
||||||
@@ -123,45 +152,27 @@ export default function WorkloadHeatmap() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat cards + aligned status heatmaps */}
|
{/* Stat cards + per-status heatmaps */}
|
||||||
<div className="grid grid-cols-4 gap-4 px-8 pb-8">
|
<div className="grid grid-cols-4 gap-4 px-8 pb-4">
|
||||||
{STATUS_KEYS.map((statusKey) => (
|
{STATUS_KEYS.map((statusKey) => (
|
||||||
<div key={statusKey} className="flex flex-col gap-3">
|
<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">
|
<div className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center">
|
||||||
<p className={`text-2xl font-bold ${STATUS_COLOR[statusKey]}`}>
|
<p className={`text-2xl font-bold ${STATUS_COLOR[statusKey]}`}>{stats[statusKey]}</p>
|
||||||
{stats[statusKey]}
|
|
||||||
</p>
|
|
||||||
<p className="text-text-muted text-xs mt-1">{STATUS_LABEL[statusKey]}</p>
|
<p className="text-text-muted text-xs mt-1">{STATUS_LABEL[statusKey]}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Heatmap filtered to this status */}
|
|
||||||
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-3 flex-1 min-h-[160px] flex items-center justify-center">
|
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-3 flex-1 min-h-[160px] flex items-center justify-center">
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 justify-center">
|
<div className="flex gap-2 overflow-x-auto pb-1 justify-center">
|
||||||
{/* Day labels */}
|
|
||||||
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
|
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
|
||||||
{DAY_INIT.map((d, i) => (
|
{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">
|
<div key={i} style={{ height: CELL }} className="flex items-center text-[9px] text-text-muted/50 font-mono w-3">{d}</div>
|
||||||
{d}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="flex flex-col flex-shrink-0">
|
<div className="flex flex-col flex-shrink-0">
|
||||||
{/* Month labels */}
|
|
||||||
<div className="relative h-4 mb-1">
|
<div className="relative h-4 mb-1">
|
||||||
{monthLabels.map(({ wi, label }) => (
|
{monthLabels.map(({ wi, label }) => (
|
||||||
<span
|
<span key={label+wi} className="absolute text-[9px] text-text-muted/60 font-medium" style={{ left: wi * (CELL + GAP) }}>{label}</span>
|
||||||
key={label+wi}
|
|
||||||
className="absolute text-[9px] text-text-muted/60 font-medium"
|
|
||||||
style={{ left: wi * (CELL + GAP) }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex" style={{ gap: GAP }}>
|
<div className="flex" style={{ gap: GAP }}>
|
||||||
{weeks.map((week, wi) => (
|
{weeks.map((week, wi) => (
|
||||||
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
|
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
|
||||||
@@ -177,20 +188,14 @@ export default function WorkloadHeatmap() {
|
|||||||
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
|
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
|
||||||
`}
|
`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!items || !items.length) return
|
if (!items?.length) return
|
||||||
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0]
|
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0]
|
||||||
if (match) openFocus(match.project.id, match.deliverable.id)
|
if (match) openFocus(match.project.id, match.deliverable.id)
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
|
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
|
||||||
if (!filtered.length) return
|
if (!filtered.length) return
|
||||||
setTooltip({
|
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey, items: filtered })
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
date,
|
|
||||||
statusKey,
|
|
||||||
items: filtered,
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => setTooltip(null)}
|
onMouseLeave={() => setTooltip(null)}
|
||||||
/>
|
/>
|
||||||
@@ -206,6 +211,82 @@ export default function WorkloadHeatmap() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Combined 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">· color = highest count</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2 justify-center">
|
||||||
|
{/* 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_LG }} className="flex items-center text-[10px] text-text-muted/50 font-mono w-4">{d}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid */}
|
||||||
|
<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) }}>{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 { 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 relative
|
||||||
|
${dominant ? getCellClass(total, dominant) : 'bg-surface border-surface-border'}
|
||||||
|
${isToday(date) ? 'ring-1 ring-white/60' : ''}
|
||||||
|
${dominant ? STATUS_HOVER_RING[dominant] : ''}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!items?.length) return
|
||||||
|
openFocus(items[0].project.id, items[0].deliverable.id)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* Tooltip */}
|
||||||
{tooltip && (
|
{tooltip && (
|
||||||
<div
|
<div
|
||||||
@@ -215,27 +296,34 @@ export default function WorkloadHeatmap() {
|
|||||||
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
|
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
|
||||||
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
|
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-[10px] mb-1.5 ${STATUS_COLOR[tooltip.statusKey]}`}>
|
{tooltip.combined && tooltip.statusCounts ? (
|
||||||
{STATUS_LABEL[tooltip.statusKey]} \u00b7 {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''}
|
<div className="space-y-1 mb-2">
|
||||||
</p>
|
{STATUS_KEYS.filter(sk => (tooltip.statusCounts[sk] || 0) > 0).map(sk => (
|
||||||
{tooltip.items.length === 0 ? (
|
<div key={sk} className="flex items-center justify-between gap-3">
|
||||||
<p className="text-text-muted/50 text-xs">No deliverables</p>
|
<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 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>
|
</div>
|
||||||
))}
|
))}
|
||||||
{tooltip.items.length > 5 && (
|
|
||||||
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
|
|
||||||
)}
|
|
||||||
</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] : ''} \u00b7 {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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user