Add files via upload
This commit is contained in:
@@ -1,50 +1,24 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format, startOfWeek, addDays, addWeeks, isToday } from 'date-fns'
|
||||
import useProjectStore from '../../store/useProjectStore'
|
||||
import useFocusStore from '../../store/useFocusStore'
|
||||
import HeatmapDayPanel from './HeatmapDayPanel'
|
||||
|
||||
const WEEKS = 20
|
||||
const WEEKS = 20
|
||||
const DAY_INIT = ['M','T','W','T','F','S','S']
|
||||
const CELL = 16
|
||||
const CELL = 16
|
||||
const CELL_LG = 40
|
||||
const GAP = 2
|
||||
const GAP_LG = 4
|
||||
const GAP = 2
|
||||
|
||||
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_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)]',
|
||||
],
|
||||
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 = {
|
||||
@@ -54,50 +28,58 @@ 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
|
||||
if (count === 1) return colors[0]
|
||||
if (count === 2) return colors[1]
|
||||
return colors[2]
|
||||
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
|
||||
let maxCount = 0
|
||||
let maxPriority = 0
|
||||
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
|
||||
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 (
|
||||
<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 openFocus = useFocusStore(s => s.openFocus)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
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), { weekStartsOn: 1 })
|
||||
const start = startOfWeek(addWeeks(new Date(), -10 + weekOffset), { weekStartsOn: 1 })
|
||||
const map = {}
|
||||
|
||||
projects.forEach(p => {
|
||||
(p.deliverables || []).forEach(d => {
|
||||
;(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')
|
||||
const s = d.status || 'upcoming'
|
||||
if (map[key].statusCounts[s] !== undefined) map[key].statusCounts[s]++
|
||||
})
|
||||
})
|
||||
@@ -111,7 +93,7 @@ export default function WorkloadHeatmap() {
|
||||
})
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -120,7 +102,13 @@ export default function WorkloadHeatmap() {
|
||||
overdue: all.filter(d => d.status === 'overdue').length,
|
||||
}
|
||||
return { weeks: grid, stats }
|
||||
}, [projects])
|
||||
}, [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
|
||||
@@ -142,14 +130,39 @@ export default function WorkloadHeatmap() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-surface overflow-auto">
|
||||
{/* Header — pl-24 clears the fixed 64px navbar button at left-4 */}
|
||||
<div className="flex items-center justify-between pl-24 pr-8 pt-6 pb-4">
|
||||
|
||||
{/* Header */}
|
||||
<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-3 text-[10px] text-text-muted">
|
||||
<span className="uppercase tracking-[0.18em] text-text-muted/60">FABDASH</span>
|
||||
|
||||
{/* Window navigation */}
|
||||
<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"
|
||||
>\u2190 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 \u2192</button>
|
||||
|
||||
<span className="text-[10px] text-text-muted/40 font-mono ml-1 hidden xl:block">{windowLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -157,53 +170,60 @@ export default function WorkloadHeatmap() {
|
||||
<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">
|
||||
|
||||
{/* 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>
|
||||
<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 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>
|
||||
|
||||
{/* Mini heatmap */}
|
||||
<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">
|
||||
{/* Day initials */}
|
||||
<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" 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 relative
|
||||
${getCellClass(count, statusKey)}
|
||||
${isToday(date) ? 'ring-1 ring-white/60' : ''}
|
||||
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
|
||||
`}
|
||||
onClick={() => {
|
||||
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 })
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{/* Grid */}
|
||||
<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>
|
||||
@@ -212,7 +232,7 @@ export default function WorkloadHeatmap() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Combined heatmap */}
|
||||
{/* 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">
|
||||
@@ -227,19 +247,16 @@ export default function WorkloadHeatmap() {
|
||||
<span className={STATUS_COLOR[sk]}>{STATUS_LABEL[sk]}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="ml-1 text-text-muted/40">· color = highest count</span>
|
||||
<span className="ml-1 text-text-muted/40">\u00b7 color = highest priority</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-[11px] 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 }) => (
|
||||
@@ -255,26 +272,15 @@ export default function WorkloadHeatmap() {
|
||||
<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
|
||||
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={() => {
|
||||
if (!items?.length) return
|
||||
openFocus(items[0].project.id, items[0].deliverable.id)
|
||||
}}
|
||||
onClick={() => setSelectedDay(date)}
|
||||
onMouseEnter={(e) => {
|
||||
if (!items?.length) return
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
date,
|
||||
statusKey: dominant,
|
||||
items,
|
||||
combined: true,
|
||||
statusCounts,
|
||||
})
|
||||
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey: dominant, items, combined: true, statusCounts })
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
@@ -288,7 +294,7 @@ export default function WorkloadHeatmap() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{/* 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]"
|
||||
@@ -325,8 +331,17 @@ export default function WorkloadHeatmap() {
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user