Merge pull request #35 from jasonMPM/pr2

Add files via upload
This commit is contained in:
jasonMPM
2026-03-06 00:19:41 -06:00
committed by GitHub
4 changed files with 365 additions and 156 deletions

View File

@@ -0,0 +1,189 @@
import { useState } from 'react'
import { format, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore'
import { updateDeliverable as apiUpdate } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal'
const STATUS_KEYS = ['overdue', 'in_progress', 'upcoming', 'completed']
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_BG = { upcoming: 'bg-blue-400/10 border-blue-400/30 hover:bg-blue-400/20', in_progress: 'bg-amber-400/10 border-amber-400/30 hover:bg-amber-400/20', completed: 'bg-green-400/10 border-green-400/30 hover:bg-green-400/20', overdue: 'bg-red-400/10 border-red-400/30 hover:bg-red-400/20' }
const STATUS_CYCLE = { upcoming: 'in_progress', in_progress: 'completed', completed: 'upcoming', overdue: 'in_progress' }
export default function HeatmapDayPanel({ date, onClose }) {
const projects = useProjectStore(s => s.projects)
const storeUpdate = useProjectStore(s => s.updateDeliverable)
const openFocus = useFocusStore(s => s.openFocus)
const { jumpToCalendarDate } = useUIStore()
const [addModal, setAddModal] = useState(false)
const [cycling, setCycling] = useState(null)
const dateStr = format(date, 'yyyy-MM-dd')
// Derive items live from store so status cycles update instantly
const items = projects.flatMap(p =>
(p.deliverables || [])
.filter(d => d.due_date === dateStr)
.map(d => ({ deliverable: d, project: p }))
)
const grouped = {}
STATUS_KEYS.forEach(k => { grouped[k] = [] })
items.forEach(({ deliverable, project }) => {
const s = deliverable.status || 'upcoming'
if (grouped[s]) grouped[s].push({ deliverable, project })
})
const handleCycle = async (deliverable) => {
const next = STATUS_CYCLE[deliverable.status] || 'upcoming'
setCycling(deliverable.id)
try {
const updated = await apiUpdate(deliverable.id, { status: next })
storeUpdate(updated)
} finally {
setCycling(null)
}
}
const handleJumpToCalendar = () => {
jumpToCalendarDate(dateStr)
onClose()
}
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/40 backdrop-blur-[1px]"
onClick={onClose}
/>
{/* Slide-in panel */}
<div className="fixed right-0 top-0 h-full w-80 z-50 bg-surface-elevated border-l border-surface-border flex flex-col shadow-2xl animate-[slide-up_0.2s_ease-out]">
{/* Header */}
<div className="flex items-start justify-between px-4 pt-4 pb-3 border-b border-surface-border flex-shrink-0">
<div>
<p className={`text-sm font-bold leading-snug ${isToday(date) ? 'text-gold' : 'text-text-primary'}`}>
{isToday(date) ? '\u2605 Today' : format(date, 'EEEE')}
</p>
<p className="text-text-muted text-xs">{format(date, 'MMMM d, yyyy')}</p>
<p className="text-text-muted/50 text-[10px] mt-0.5">
{items.length} deliverable{items.length !== 1 ? 's' : ''}
</p>
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<button
onClick={handleJumpToCalendar}
className="text-[10px] px-2 py-1.5 rounded-lg border border-surface-border bg-surface text-text-muted hover:text-gold hover:border-gold/40 transition-all"
title="Switch to calendar and jump to this date"
>
\u2192 Calendar
</button>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary transition-colors text-base w-7 h-7 flex items-center justify-center rounded hover:bg-surface-border/40"
>
\u2715
</button>
</div>
</div>
{/* Add Deliverable */}
<div className="px-3 py-2.5 border-b border-surface-border/60 flex-shrink-0">
<button
onClick={() => setAddModal(true)}
className="w-full text-xs py-2 rounded-lg border border-dashed border-gold/25 text-gold/60 hover:border-gold/60 hover:text-gold hover:bg-gold/5 transition-all"
>
+ Schedule for {format(date, 'MMM d')}
</button>
</div>
{/* Deliverable list grouped by status */}
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-4">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="text-3xl mb-3 opacity-20">\u25a1</div>
<p className="text-text-muted/60 text-sm">Nothing scheduled</p>
<p className="text-text-muted/30 text-xs mt-1">Click + Schedule above to add one</p>
</div>
) : (
STATUS_KEYS.filter(k => grouped[k].length > 0).map(statusKey => (
<div key={statusKey}>
{/* Status group header */}
<div className="flex items-center gap-2 mb-1.5 px-0.5">
<span className={`text-[10px] font-bold uppercase tracking-widest ${STATUS_COLOR[statusKey]}`}>
{STATUS_LABEL[statusKey]}
</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded-full border font-mono ${STATUS_BG[statusKey]} ${STATUS_COLOR[statusKey]}`}>
{grouped[statusKey].length}
</span>
</div>
<div className="space-y-1.5">
{grouped[statusKey].map(({ deliverable, project }) => (
<div
key={deliverable.id}
className="flex items-center gap-2 bg-surface rounded-lg px-2.5 py-2 border border-surface-border hover:border-gold/20 transition-colors group"
>
{/* Project color dot */}
<div
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: project.color }}
/>
{/* Title + project */}
<div className="flex-1 min-w-0">
<p className="text-xs text-text-primary truncate leading-snug">{deliverable.title}</p>
<p className="text-[10px] text-text-muted/50 truncate">{project.name}</p>
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-60 group-hover:opacity-100 transition-opacity">
{/* Cycle status */}
<button
onClick={() => handleCycle(deliverable)}
disabled={cycling === deliverable.id}
className={`text-[10px] w-6 h-6 flex items-center justify-center rounded border transition-all ${STATUS_COLOR[statusKey]} border-current/30 hover:border-current hover:bg-current/10 disabled:opacity-30`}
title={`Mark as ${STATUS_LABEL[STATUS_CYCLE[deliverable.status]]}`}
>
{cycling === deliverable.id ? '\u2026' : '\u27f3'}
</button>
{/* Open Focus View */}
<button
onClick={() => { openFocus(project.id, deliverable.id); onClose() }}
className="text-[10px] w-6 h-6 flex items-center justify-center rounded border border-surface-border text-text-muted hover:text-gold hover:border-gold/40 transition-all"
title="Open Focus View"
>
\u25ce
</button>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
{/* Footer: cycle legend */}
<div className="flex-shrink-0 px-4 py-2.5 border-t border-surface-border/50">
<p className="text-[9px] text-text-muted/30 text-center">
\u27f3 cycles status &nbsp;\u00b7&nbsp; \u25ce opens Focus View &nbsp;\u00b7&nbsp; \u2192 Calendar jumps to date
</p>
</div>
</div>
{/* Add Deliverable modal */}
<DeliverableModal
isOpen={addModal}
onClose={() => setAddModal(false)}
deliverable={null}
defaultDate={dateStr}
/>
</>
)
}

View File

@@ -17,9 +17,9 @@ export default function MainCalendar({ onCalendarReady }) {
const calRef = useRef(null)
const wrapperRef = useRef(null)
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
const openFocus = useFocusStore(s => s.openFocus)
const { showHeatmap, toggleHeatmap } = useUIStore()
const addToast = useToastStore(s => s.addToast)
const openFocus = useFocusStore(s => s.openFocus)
const { showHeatmap, toggleHeatmap, heatmapJumpDate, clearJumpDate } = useUIStore()
const addToast = useToastStore(s => s.addToast)
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
const [contextMenu, setContextMenu] = useState(null)
@@ -27,23 +27,25 @@ export default function MainCalendar({ onCalendarReady }) {
// Expose calendar API to App.jsx for keyboard shortcuts
useEffect(() => {
if (calRef.current && onCalendarReady) {
onCalendarReady(calRef.current.getApi())
}
if (calRef.current && onCalendarReady) onCalendarReady(calRef.current.getApi())
}, [])
// ResizeObserver: call updateSize() on every frame the container changes width
// during the sidebar CSS transition so FullCalendar reflows smoothly
// ResizeObserver: smooth reflow during sidebar CSS transition
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const ro = new ResizeObserver(() => {
calRef.current?.getApi().updateSize()
})
const ro = new ResizeObserver(() => calRef.current?.getApi().updateSize())
ro.observe(el)
return () => ro.disconnect()
}, [])
// Jump to date commanded by HeatmapDayPanel
useEffect(() => {
if (!heatmapJumpDate || !calRef.current) return
calRef.current.getApi().gotoDate(heatmapJumpDate)
clearJumpDate()
}, [heatmapJumpDate, clearJumpDate])
const events = projects.flatMap(p =>
(p.deliverables || []).map(d => ({
id: String(d.id),
@@ -82,17 +84,14 @@ export default function MainCalendar({ onCalendarReady }) {
})
}, [storeUpdate, addToast])
// Click empty date - open add modal
const handleDateClick = useCallback(({ dateStr }) => {
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) })
}, [])
// Date range drag-select - pre-fill modal with start date
const handleSelect = useCallback(({ startStr }) => {
setModal({ open: true, deliverable: null, defaultDate: startStr.substring(0, 10) })
}, [])
// Attach dblclick + contextmenu + tooltip via eventDidMount
const handleEventDidMount = useCallback(({ event, el }) => {
const { deliverableId, projectId } = event.extendedProps
@@ -104,14 +103,12 @@ export default function MainCalendar({ onCalendarReady }) {
el.addEventListener('mousemove', (e) => {
setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
})
el.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { deliverable } = getCtx(projectId, deliverableId)
if (deliverable) setModal({ open: true, deliverable, defaultDate: '' })
})
el.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
@@ -121,15 +118,16 @@ export default function MainCalendar({ onCalendarReady }) {
x: e.clientX, y: e.clientY,
items: [
{ icon: '\u2714\ufe0e', label: 'Edit Deliverable', action: () => setModal({ open: true, deliverable, defaultDate: '' }) },
{ icon: '\u2756', label: 'Open Focus View', action: () => openFocus(projectId, deliverableId) },
{ icon: '\u2756', label: 'Open Focus View', action: () => openFocus(projectId, deliverableId) },
...(project?.drive_url ? [{ icon: '\u2b21', label: 'Open Drive Folder', action: () => window.open(project.drive_url, '_blank') }] : []),
{ separator: true },
{ icon: '\u2715', label: 'Delete Deliverable', danger: true,
action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverableId); removeDeliverable(deliverableId)
await deleteDeliverable(deliverableId)
removeDeliverable(deliverableId)
}
}
},
},
],
})
@@ -138,19 +136,22 @@ export default function MainCalendar({ onCalendarReady }) {
return (
<div className="h-full flex flex-col bg-surface" onContextMenu={e => e.preventDefault()}>
{/* View toggle toolbar */}
<div className="flex items-center justify-end gap-2 px-4 pt-3 pb-0 flex-shrink-0">
<button onClick={toggleHeatmap}
<button
onClick={toggleHeatmap}
className={`text-xs px-3 py-1.5 rounded-lg border transition-all font-medium
${showHeatmap
? 'bg-gold text-surface border-gold'
: 'bg-surface-elevated border-surface-border text-text-muted hover:border-gold/40 hover:text-gold'
}`}>
}`}
>
{showHeatmap ? '\u2190 Calendar' : '\u2b21 Heatmap'}
</button>
</div>
{/* Main content area */}
{/* Main content */}
<div className="flex-1 overflow-hidden">
{showHeatmap ? (
<WorkloadHeatmap />

View File

@@ -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>
)
}

View File

@@ -1,13 +1,17 @@
import { create } from 'zustand'
const useUIStore = create((set) => ({
sidebarOpen: true,
sidebarTab: 'projects', // 'projects' | 'agenda'
showHeatmap: false,
export default create((set) => ({
sidebarOpen: true,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarTab: (tab) => set({ sidebarTab: tab }),
toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
sidebarTab: 'projects',
setSidebarTab: (tab) => set({ sidebarTab: tab }),
showHeatmap: false,
toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
// Set by HeatmapDayPanel "Jump to Calendar" — MainCalendar watches and navigates
heatmapJumpDate: null,
jumpToCalendarDate: (date) => set({ heatmapJumpDate: date, showHeatmap: false }),
clearJumpDate: () => set({ heatmapJumpDate: null }),
}))
export default useUIStore