Add files via upload

This commit is contained in:
jasonMPM
2026-03-05 17:05:47 -06:00
committed by GitHub
parent ef8231cb4c
commit 78e1092aa9

View File

@@ -1,16 +1,30 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { format, startOfWeek, addDays, addWeeks, isSameDay, parseISO, isToday } from 'date-fns' import { format, startOfWeek, addDays, addWeeks, parseISO, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore' import useFocusStore from '../../store/useFocusStore'
import Badge from '../UI/Badge'
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']
function getCellStyle(count) { const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
if (count === 0) return 'bg-surface border-surface-border' const STATUS_LABEL = {
if (count === 1) return 'bg-gold/25 border-gold/40' upcoming: 'Upcoming',
if (count === 2) return 'bg-gold/55 border-gold/70' 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'
return 'bg-gold border-gold shadow-gold' return 'bg-gold border-gold shadow-gold'
} }
@@ -22,10 +36,14 @@ export default function WorkloadHeatmap() {
const { weeks, stats } = useMemo(() => { const { weeks, stats } = useMemo(() => {
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 }) const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
const map = {} const map = {}
projects.forEach(p => { projects.forEach(p => {
(p.deliverables || []).forEach(d => { (p.deliverables || []).forEach(d => {
if (!map[d.due_date]) map[d.due_date] = [] const key = d.due_date
map[d.due_date].push({ deliverable: d, project: p }) 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]++
}) })
}) })
@@ -33,17 +51,18 @@ export default function WorkloadHeatmap() {
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')
return { date, key, items: map[key] || [] } 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 = { const stats = {
total: all.length, total: all.length,
overdue: all.filter(d => d.status === 'overdue').length, upcoming: all.filter(d => d.status === 'upcoming').length,
in_progress: all.filter(d => d.status === 'in_progress').length, in_progress: all.filter(d => d.status === 'in_progress').length,
completed: all.filter(d => d.status === 'completed').length, completed: all.filter(d => d.status === 'completed').length,
upcoming: all.filter(d => d.status === 'upcoming').length, overdue: all.filter(d => d.status === 'overdue').length,
} }
return { weeks: grid, stats } return { weeks: grid, stats }
}, [projects]) }, [projects])
@@ -57,47 +76,67 @@ export default function WorkloadHeatmap() {
return labels return labels
}, [weeks]) }, [weeks])
const CELL = 20, GAP = 3 const CELL = 18
const GAP = 3
return ( return (
<div className="flex flex-col h-full bg-surface overflow-auto p-6"> <div className="flex flex-col h-full bg-surface overflow-auto">
{/* Header */} {/* Header with spacing that clears FABDASH corner logo */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between px-8 pt-6 pb-4">
<div> <div>
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2> <h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2>
<p className="text-text-muted text-xs mt-0.5">{WEEKS} weeks of deliverable density</p> <p className="text-text-muted text-xs mt-0.5">20 weeks of deliverable density by status</p>
</div> </div>
<div className="flex items-center gap-2 text-xs text-text-muted"> <div className="flex items-center gap-3 text-[10px] text-text-muted">
<span>Less</span> <span className="uppercase tracking-[0.18em] text-text-muted/60">FABDASH</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-4 h-4 rounded-sm border ${c}`} />
))}
<span>More</span>
</div> </div>
</div> </div>
{/* Stat cards */} {/* Stat cards by status */}
<div className="grid grid-cols-5 gap-3 mb-6"> <div className="grid grid-cols-5 gap-3 px-8 mb-4">
{[ {[
{ label: 'Total', value: stats.total, color: 'text-text-primary' }, { key: 'total', label: 'Total', color: 'text-text-primary' },
{ label: 'Upcoming', value: stats.upcoming, color: 'text-blue-400' }, { key: 'upcoming', label: 'Upcoming', color: 'text-blue-400' },
{ label: 'In Progress', value: stats.in_progress, color: 'text-amber-400' }, { key: 'in_progress', label: 'In Progress', color: 'text-amber-400' },
{ label: 'Completed', value: stats.completed, color: 'text-green-400' }, { key: 'completed', label: 'Completed', color: 'text-green-400' },
{ label: 'Overdue', value: stats.overdue, color: 'text-red-400' }, { key: 'overdue', label: 'Overdue', color: 'text-red-400' },
].map(({ label, value, color }) => ( ].map(({ key, label, color }) => (
<div key={label} className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center"> <div key={key} className="bg-surface-elevated border border-surface-border rounded-xl p-4 text-center">
<p className={`text-2xl font-bold ${color}`}>{value}</p> <p className={`text-2xl font-bold ${color}`}>{stats[key]}</p>
<p className="text-text-muted text-xs mt-1">{label}</p> <p className="text-text-muted text-xs mt-1">{label}</p>
</div> </div>
))} ))}
</div> </div>
{/* Heatmap grid */} {/* Multi-row heatmaps by status */}
<div className="flex gap-3 overflow-x-auto pb-4"> <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>
<div className="flex gap-3 overflow-x-auto pb-2">
{/* Day labels */} {/* 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-[10px] text-text-muted/50 font-mono w-4">{d}</div> <div key={i} style={{ height: CELL }} className="flex items-center text-[10px] text-text-muted/50 font-mono w-4">
{d}
</div>
))} ))}
</div> </div>
@@ -106,41 +145,69 @@ export default function WorkloadHeatmap() {
{/* Month labels */} {/* Month labels */}
<div className="relative h-5 mb-1"> <div className="relative h-5 mb-1">
{monthLabels.map(({ wi, label }) => ( {monthLabels.map(({ wi, label }) => (
<span key={label+wi} className="absolute text-[10px] text-text-muted/60 font-medium" <span
style={{ left: wi * (CELL + GAP) }}> key={label+wi}
className="absolute text-[10px] text-text-muted/60 font-medium"
style={{ left: wi * (CELL + GAP) }}
>
{label} {label}
</span> </span>
))} ))}
</div> </div>
{/* Week columns */}
<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 }}>
{week.map(({ date, key, items }) => ( {week.map(({ date, key, items, statusCounts }) => {
<div key={key} const countForStatus = (statusCounts || {})[statusKey] || 0
const baseDensity = countForStatus
return (
<div
key={key + statusKey}
style={{ width: CELL, height: CELL }} style={{ width: CELL, height: CELL }}
className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative
${getCellStyle(items.length)} ${getCellClass(baseDensity, statusCounts || {})}
${isToday(date) ? 'ring-1 ring-white/40' : ''} ${isToday(date) ? 'ring-1 ring-white/40' : ''}
`} `}
onClick={() => items.length > 0 && openFocus(items[0].project.id, items[0].deliverable.id)} onClick={() => {
onMouseEnter={(e) => setTooltip({ x: e.clientX, y: e.clientY, date, items })} 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)} onMouseLeave={() => setTooltip(null)}
/> />
))} )
})}
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div>
))}
{/* Tooltip */}
{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-[200px] max-w-[280px]" <div
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 290), top: Math.max(tooltip.y - 100, 8) }}> 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'}`}> <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')} {isToday(tooltip.date) ? 'Today — ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
</p> </p>
<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>
{tooltip.items.length === 0 ? ( {tooltip.items.length === 0 ? (
<p className="text-text-muted/50 text-xs">No deliverables</p> <p className="text-text-muted/50 text-xs">No deliverables</p>
) : ( ) : (
@@ -162,5 +229,6 @@ export default function WorkloadHeatmap() {
</div> </div>
)} )}
</div> </div>
</div>
) )
} }