From 748acc41c352ca2b1258d040575b6ec909c24744 Mon Sep 17 00:00:00 2001 From: jasonMPM Date: Fri, 6 Mar 2026 00:19:24 -0600 Subject: [PATCH] Add files via upload --- .../components/Calendar/HeatmapDayPanel.jsx | 189 +++++++++++++ .../src/components/Calendar/MainCalendar.jsx | 45 +-- .../components/Calendar/WorkloadHeatmap.jsx | 265 +++++++++--------- frontend/src/store/useUIStore.js | 22 +- 4 files changed, 365 insertions(+), 156 deletions(-) create mode 100644 frontend/src/components/Calendar/HeatmapDayPanel.jsx diff --git a/frontend/src/components/Calendar/HeatmapDayPanel.jsx b/frontend/src/components/Calendar/HeatmapDayPanel.jsx new file mode 100644 index 0000000..03d2edc --- /dev/null +++ b/frontend/src/components/Calendar/HeatmapDayPanel.jsx @@ -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 */} +
+ + {/* Slide-in panel */} +
+ + {/* Header */} +
+
+

+ {isToday(date) ? '\u2605 Today' : format(date, 'EEEE')} +

+

{format(date, 'MMMM d, yyyy')}

+

+ {items.length} deliverable{items.length !== 1 ? 's' : ''} +

+
+
+ + +
+
+ + {/* Add Deliverable */} +
+ +
+ + {/* Deliverable list grouped by status */} +
+ {items.length === 0 ? ( +
+
\u25a1
+

Nothing scheduled

+

Click + Schedule above to add one

+
+ ) : ( + STATUS_KEYS.filter(k => grouped[k].length > 0).map(statusKey => ( +
+ {/* Status group header */} +
+ + {STATUS_LABEL[statusKey]} + + + {grouped[statusKey].length} + +
+ +
+ {grouped[statusKey].map(({ deliverable, project }) => ( +
+ {/* Project color dot */} +
+ + {/* Title + project */} +
+

{deliverable.title}

+

{project.name}

+
+ + {/* Actions */} +
+ {/* Cycle status */} + + {/* Open Focus View */} + +
+
+ ))} +
+
+ )) + )} +
+ + {/* Footer: cycle legend */} +
+

+ \u27f3 cycles status  \u00b7  \u25ce opens Focus View  \u00b7  \u2192 Calendar jumps to date +

+
+
+ + {/* Add Deliverable modal */} + setAddModal(false)} + deliverable={null} + defaultDate={dateStr} + /> + + ) +} diff --git a/frontend/src/components/Calendar/MainCalendar.jsx b/frontend/src/components/Calendar/MainCalendar.jsx index 37c9eb4..fa021bb 100644 --- a/frontend/src/components/Calendar/MainCalendar.jsx +++ b/frontend/src/components/Calendar/MainCalendar.jsx @@ -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 (
e.preventDefault()}> + {/* View toggle toolbar */}
-
- {/* Main content area */} + {/* Main content */}
{showHeatmap ? ( diff --git a/frontend/src/components/Calendar/WorkloadHeatmap.jsx b/frontend/src/components/Calendar/WorkloadHeatmap.jsx index 153763b..73e8a94 100644 --- a/frontend/src/components/Calendar/WorkloadHeatmap.jsx +++ b/frontend/src/components/Calendar/WorkloadHeatmap.jsx @@ -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 ( +
+ Less +
+
+
+
+ More +
+ ) +} + 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 (
- {/* Header — pl-24 clears the fixed 64px navbar button at left-4 */} -
+ + {/* Header */} +

Workload Heatmap

20 weeks of deliverable density by status

-
- FABDASH + + {/* Window navigation */} +
+ + + + + + + {windowLabel}
@@ -157,53 +170,60 @@ export default function WorkloadHeatmap() {
{STATUS_KEYS.map((statusKey) => (
+ + {/* Stat card */}

{stats[statusKey]}

{STATUS_LABEL[statusKey]}

-
-
-
- {DAY_INIT.map((d, i) => ( -
{d}
- ))} -
-
-
- {monthLabels.map(({ wi, label }) => ( - {label} + + {/* Mini heatmap */} +
+
+ +
+
+
+ {/* Day initials */} +
+ {DAY_INIT.map((d, i) => ( +
{d}
))}
-
- {weeks.map((week, wi) => ( -
- {week.map(({ date, key, items, statusCounts }) => { - const count = (statusCounts || {})[statusKey] || 0 - return ( -
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)} - /> - ) - })} -
- ))} + {/* Grid */} +
+
+ {monthLabels.map(({ wi, label }) => ( + {label} + ))} +
+
+ {weeks.map((week, wi) => ( +
+ {week.map(({ date, key, items, statusCounts }) => { + const count = (statusCounts || {})[statusKey] || 0 + return ( +
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)} + /> + ) + })} +
+ ))} +
@@ -212,7 +232,7 @@ export default function WorkloadHeatmap() { ))}
- {/* Combined heatmap */} + {/* Combined All Tasks heatmap */}
@@ -227,19 +247,16 @@ export default function WorkloadHeatmap() { {STATUS_LABEL[sk]}
))} - · color = highest count + \u00b7 color = highest priority
- {/* Day labels */}
{DAY_INIT.map((d, i) => (
{d}
))}
- - {/* Grid */}
{monthLabelsBig.map(({ wi, label }) => ( @@ -255,26 +272,15 @@ export default function WorkloadHeatmap() {
{ - 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() {
- {/* Tooltip */} + {/* Hover tooltip */} {tooltip && (
+{tooltip.items.length - 5} more

)}
+

Click to open day detail

)} + + {/* Day detail panel */} + {selectedDay && ( + setSelectedDay(null)} + /> + )}
) } diff --git a/frontend/src/store/useUIStore.js b/frontend/src/store/useUIStore.js index 8addc01..f34303a 100644 --- a/frontend/src/store/useUIStore.js +++ b/frontend/src/store/useUIStore.js @@ -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