diff --git a/frontend/src/components/Calendar/WorkloadHeatmap.jsx b/frontend/src/components/Calendar/WorkloadHeatmap.jsx index 6ba61a2..a6c4fa0 100644 --- a/frontend/src/components/Calendar/WorkloadHeatmap.jsx +++ b/frontend/src/components/Calendar/WorkloadHeatmap.jsx @@ -5,6 +5,9 @@ import useFocusStore from '../../store/useFocusStore' const WEEKS = 20 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_LABEL = { @@ -20,7 +23,6 @@ const STATUS_COLOR = { overdue: 'text-red-400', } -// Cell colors per status, three density levels: low / mid / high const STATUS_CELL_COLORS = { upcoming: [ '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', } +// Tie-break priority: overdue > in_progress > upcoming > completed +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 colors = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming @@ -59,6 +64,24 @@ function getCellClass(count, statusKey) { 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() { const projects = useProjectStore(s => s.projects) const openFocus = useFocusStore(s => s.openFocus) @@ -80,14 +103,14 @@ export default function WorkloadHeatmap() { 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 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 all = projects.flatMap(p => p.deliverables || []) const stats = { total: all.length, upcoming: all.filter(d => d.status === 'upcoming').length, @@ -107,8 +130,14 @@ export default function WorkloadHeatmap() { return labels }, [weeks]) - const CELL = 16 - const GAP = 2 + 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 (
@@ -123,45 +152,27 @@ export default function WorkloadHeatmap() {
- {/* Stat cards + aligned status heatmaps */} -
+ {/* Stat cards + per-status heatmaps */} +
{STATUS_KEYS.map((statusKey) => (
- {/* Stat card */}
-

- {stats[statusKey]} -

+

{stats[statusKey]}

{STATUS_LABEL[statusKey]}

- - {/* Heatmap filtered to this status */}
- {/* Day labels */}
{DAY_INIT.map((d, i) => ( -
- {d} -
+
{d}
))}
- - {/* Grid */}
- {/* Month labels */}
{monthLabels.map(({ wi, label }) => ( - - {label} - + {label} ))}
-
{weeks.map((week, wi) => (
@@ -177,20 +188,14 @@ export default function WorkloadHeatmap() { ${count > 0 ? STATUS_HOVER_RING[statusKey] : ''} `} onClick={() => { - if (!items || !items.length) return + if (!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) if (!filtered.length) return - setTooltip({ - x: e.clientX, - y: e.clientY, - date, - statusKey, - items: filtered, - }) + setTooltip({ x: e.clientX, y: e.clientY, date, statusKey, items: filtered }) }} onMouseLeave={() => setTooltip(null)} /> @@ -206,6 +211,82 @@ export default function WorkloadHeatmap() { ))}
+ {/* Combined heatmap */} +
+
+
+
+

All Tasks

+ {stats.total} tasks +
+
+ {STATUS_KEYS.map(sk => ( +
+
+ {STATUS_LABEL[sk]} +
+ ))} + ยท color = highest count +
+
+ +
+ {/* Day labels */} +
+ {DAY_INIT.map((d, i) => ( +
{d}
+ ))} +
+ + {/* Grid */} +
+
+ {monthLabelsBig.map(({ wi, label }) => ( + {label} + ))} +
+
+ {weeks.map((week, wi) => ( +
+ {week.map(({ date, key, items, statusCounts }) => { + const { dominant, total } = getDominantStatus(statusCounts || {}) + return ( +
{ + 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)} + /> + ) + })} +
+ ))} +
+
+
+
+
+ {/* Tooltip */} {tooltip && (
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}

-

- {STATUS_LABEL[tooltip.statusKey]} \u00b7 {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''} -

- {tooltip.items.length === 0 ? ( -

No deliverables

- ) : ( -
- {tooltip.items.slice(0, 5).map(({ deliverable, project }) => ( -
-
-
-

{deliverable.title}

-

{project.name}

-
+ {tooltip.combined && tooltip.statusCounts ? ( +
+ {STATUS_KEYS.filter(sk => (tooltip.statusCounts[sk] || 0) > 0).map(sk => ( +
+ {STATUS_LABEL[sk]} + {tooltip.statusCounts[sk]}
))} - {tooltip.items.length > 5 && ( -

+{tooltip.items.length - 5} more

- )}
+ ) : ( +

+ {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

+ )} +
)}