Add files via upload

This commit is contained in:
jasonMPM
2026-03-05 15:39:21 -06:00
committed by GitHub
parent 3333dc59d8
commit edb0e3a539
11 changed files with 592 additions and 104 deletions

View File

@@ -0,0 +1,81 @@
import { useMemo } from 'react'
import { format, parseISO, isAfter, isBefore, addDays, startOfDay, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import Badge from '../UI/Badge'
export default function AgendaPanel() {
const projects = useProjectStore(s => s.projects)
const openFocus = useFocusStore(s => s.openFocus)
const grouped = useMemo(() => {
const today = startOfDay(new Date())
const cutoff = addDays(today, 60)
const items = []
projects.forEach(p => {
(p.deliverables || []).forEach(d => {
const dt = parseISO(d.due_date)
if (!isBefore(dt, today) && !isAfter(dt, cutoff)) {
items.push({ ...d, projectName: p.name, projectColor: p.color, projectId: p.id })
}
})
})
items.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))
const map = {}
items.forEach(item => {
if (!map[item.due_date]) map[item.due_date] = []
map[item.due_date].push(item)
})
return map
}, [projects])
const dates = Object.keys(grouped).sort()
if (dates.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="opacity-20 mb-3">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="6" y="10" width="36" height="32" rx="3" stroke="#C9A84C" strokeWidth="1.5"/>
<line x1="6" y1="18" x2="42" y2="18" stroke="#C9A84C" strokeWidth="1.5"/>
<circle cx="15" cy="6" r="3" stroke="#C9A84C" strokeWidth="1.5"/>
<circle cx="33" cy="6" r="3" stroke="#C9A84C" strokeWidth="1.5"/>
</svg>
</div>
<p className="text-text-muted text-sm">All clear for 60 days</p>
<p className="text-text-muted/50 text-xs mt-1">No upcoming deliverables</p>
</div>
)
}
return (
<div className="flex flex-col pb-4">
<p className="text-[10px] text-text-muted/50 uppercase tracking-widest px-4 py-2 border-b border-surface-border">
Next 60 days · {dates.length} due date{dates.length !== 1 ? 's' : ''}
</p>
{dates.map(date => (
<div key={date}>
<div className="sticky top-0 bg-surface-raised px-4 py-1.5 border-b border-surface-border/50">
<span className={`text-[11px] font-bold uppercase tracking-widest ${isToday(parseISO(date)) ? 'text-gold' : 'text-text-muted/70'}`}>
{isToday(parseISO(date)) ? 'Today' : format(parseISO(date), 'EEE, MMM d')}
</span>
</div>
{grouped[date].map(d => (
<button key={d.id} onClick={() => openFocus(d.projectId, d.id)}
className="w-full flex items-center gap-2.5 px-4 py-2 hover:bg-surface-border/20 transition-colors text-left group">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: d.projectColor }} />
<div className="flex-1 min-w-0">
<p className="text-xs text-text-primary group-hover:text-gold transition-colors truncate">{d.title}</p>
<p className="text-[10px] text-text-muted/60 truncate">{d.projectName}</p>
</div>
<Badge status={d.status} />
</button>
))}
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
export default function EventTooltip({ tooltip }) {
if (!tooltip) return null
const { x, y, project, deliverable } = tooltip
const tx = Math.min(x + 14, window.innerWidth - 230)
const ty = Math.max(y - 90, 8)
return (
<div className="fixed z-[150] pointer-events-none bg-surface-elevated border border-gold/20 rounded-xl shadow-gold px-3.5 py-3 min-w-[190px] max-w-[240px]"
style={{ left: tx, top: ty }}>
<div className="flex items-center gap-1.5 mb-1.5">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: project?.color }} />
<span className="text-gold text-xs font-semibold truncate">{project?.name}</span>
</div>
<p className="text-text-primary text-xs font-medium leading-snug mb-1.5">{deliverable?.title}</p>
<div className="flex items-center justify-between">
<span className="text-text-muted/70 text-[10px] font-mono">{formatDate(deliverable?.due_date)}</span>
<Badge status={deliverable?.status} />
</div>
</div>
)
}

View File

@@ -1,21 +1,35 @@
import { useRef, useState, useCallback } from 'react'
import { useRef, useState, useCallback, useEffect } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore'
import useToastStore from '../../store/useToastStore'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu'
import EventTooltip from './EventTooltip'
import WorkloadHeatmap from './WorkloadHeatmap'
export default function MainCalendar() {
export default function MainCalendar({ onCalendarReady }) {
const calRef = useRef(null)
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
const openFocus = useFocusStore(s => s.openFocus)
const openFocus = useFocusStore(s => s.openFocus)
const { showHeatmap, toggleHeatmap } = useUIStore()
const addToast = useToastStore(s => s.addToast)
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
const [contextMenu, setContextMenu] = useState(null)
const [tooltip, setTooltip] = useState(null)
// Expose calendar API to App.jsx for keyboard shortcuts
useEffect(() => {
if (calRef.current && onCalendarReady) {
onCalendarReady(calRef.current.getApi())
}
}, [])
const events = projects.flatMap(p =>
(p.deliverables || []).map(d => ({
@@ -29,70 +43,80 @@ export default function MainCalendar() {
}))
)
const getDeliverable = (projectId, deliverableId) => {
const p = projects.find(p => p.id === projectId)
return { project: p, deliverable: p?.deliverables.find(d => d.id === deliverableId) }
const getCtx = (projectId, deliverableId) => {
const project = projects.find(p => p.id === projectId)
const deliverable = project?.deliverables.find(d => d.id === deliverableId)
return { project, deliverable }
}
// Single click → Focus View
const handleEventClick = useCallback(({ event }) => {
const { deliverableId, projectId } = event.extendedProps
openFocus(projectId, deliverableId)
}, [openFocus])
// Drag-and-drop → patch date
const handleEventDrop = useCallback(async ({ event }) => {
// Drag-and-drop with 30-second undo toast
const handleEventDrop = useCallback(async ({ event, oldEvent }) => {
const { deliverableId } = event.extendedProps
storeUpdate(await updateDeliverable(deliverableId, { due_date: event.startStr.substring(0, 10) }))
}, [storeUpdate])
const newDate = event.startStr.substring(0, 10)
const oldDate = oldEvent.startStr.substring(0, 10)
storeUpdate(await updateDeliverable(deliverableId, { due_date: newDate }))
addToast({
message: `Moved to ${newDate}`,
duration: 30,
undoFn: async () => {
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate }))
},
})
}, [storeUpdate, addToast])
// Click empty date → add deliverable
// Click empty date — open add modal
const handleDateClick = useCallback(({ dateStr }) => {
setModal({ open: true, deliverable: null, defaultDate: dateStr.substring(0, 10) })
}, [])
// Attach dblclick + contextmenu to each event element after mount
// 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
// Double-click → open edit modal directly
el.addEventListener('mouseenter', (e) => {
const { project, deliverable } = getCtx(projectId, deliverableId)
setTooltip({ x: e.clientX, y: e.clientY, project, deliverable })
})
el.addEventListener('mouseleave', () => setTooltip(null))
el.addEventListener('mousemove', (e) => {
setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
})
el.addEventListener('dblclick', (e) => {
e.preventDefault()
e.stopPropagation()
const { deliverable } = getDeliverable(projectId, deliverableId)
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { deliverable } = getCtx(projectId, deliverableId)
if (deliverable) setModal({ open: true, deliverable, defaultDate: '' })
})
// Right-click → context menu
el.addEventListener('contextmenu', (e) => {
e.preventDefault()
e.stopPropagation()
const { project, deliverable } = getDeliverable(projectId, deliverableId)
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { project, deliverable } = getCtx(projectId, deliverableId)
if (!deliverable) return
setContextMenu({
x: e.clientX, y: e.clientY,
items: [
{
icon: '', label: 'Edit Deliverable',
action: () => setModal({ open: true, deliverable, defaultDate: '' }),
},
{
icon: '◎', label: 'Open Focus View',
action: () => openFocus(projectId, deliverableId),
},
...(project?.drive_url ? [{
icon: '⬡', label: 'Open Drive Folder',
action: () => window.open(project.drive_url, '_blank'),
}] : []),
{ icon: '✎', label: 'Edit Deliverable', action: () => setModal({ open: true, deliverable, defaultDate: '' }) },
{ icon: '', label: 'Open Focus View', action: () => openFocus(projectId, deliverableId) },
...(project?.drive_url ? [{ icon: '⬡', label: 'Open Drive Folder', action: () => window.open(project.drive_url, '_blank') }] : []),
{ separator: true },
{
icon: '✕', label: 'Delete Deliverable', danger: true,
{ icon: '✕', label: 'Delete Deliverable', danger: true,
action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverableId)
removeDeliverable(deliverableId)
await deleteDeliverable(deliverableId); removeDeliverable(deliverableId)
}
},
}
},
],
})
@@ -100,24 +124,46 @@ export default function MainCalendar() {
}, [projects, openFocus])
return (
<div className="h-full flex flex-col bg-surface p-4" onContextMenu={e => e.preventDefault()}>
<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}
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 ? '← Calendar' : '⬡ Heatmap'}
</button>
</div>
{/* Main content area */}
<div className="flex-1 overflow-hidden">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
events={events}
editable={true}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
dateClick={handleDateClick}
eventDidMount={handleEventDidMount}
height="100%"
dayMaxEvents={4}
eventDisplay="block"
displayEventTime={false}
/>
{showHeatmap ? (
<WorkloadHeatmap />
) : (
<div className="h-full p-4 pt-2">
<FullCalendar
ref={calRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{ left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }}
events={events}
editable={true}
selectable={true}
weekNumbers={true}
eventDrop={handleEventDrop}
eventClick={handleEventClick}
dateClick={handleDateClick}
select={handleSelect}
eventDidMount={handleEventDidMount}
height="100%"
dayMaxEvents={4}
eventDisplay="block"
displayEventTime={false}
/>
</div>
)}
</div>
<DeliverableModal
@@ -128,13 +174,10 @@ export default function MainCalendar() {
/>
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={contextMenu.items}
onClose={() => setContextMenu(null)}
/>
<ContextMenu x={contextMenu.x} y={contextMenu.y} items={contextMenu.items} onClose={() => setContextMenu(null)} />
)}
<EventTooltip tooltip={tooltip} />
</div>
)
}

View File

@@ -0,0 +1,166 @@
import { useMemo, useState } from 'react'
import { format, startOfWeek, addDays, addWeeks, isSameDay, parseISO, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import Badge from '../UI/Badge'
const WEEKS = 20
const DAY_INIT = ['M','T','W','T','F','S','S']
function getCellStyle(count) {
if (count === 0) return 'bg-surface border-surface-border'
if (count === 1) return 'bg-gold/25 border-gold/40'
if (count === 2) return 'bg-gold/55 border-gold/70'
return 'bg-gold border-gold shadow-gold'
}
export default function WorkloadHeatmap() {
const projects = useProjectStore(s => s.projects)
const openFocus = useFocusStore(s => s.openFocus)
const [tooltip, setTooltip] = useState(null)
const { weeks, stats } = useMemo(() => {
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
const map = {}
projects.forEach(p => {
(p.deliverables || []).forEach(d => {
if (!map[d.due_date]) map[d.due_date] = []
map[d.due_date].push({ deliverable: d, project: p })
})
})
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')
return { date, key, items: map[key] || [] }
})
)
const all = projects.flatMap(p => p.deliverables || [])
const stats = {
total: all.length,
overdue: all.filter(d => d.status === 'overdue').length,
in_progress: all.filter(d => d.status === 'in_progress').length,
completed: all.filter(d => d.status === 'completed').length,
upcoming: all.filter(d => d.status === 'upcoming').length,
}
return { weeks: grid, stats }
}, [projects])
const monthLabels = 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])
const CELL = 20, GAP = 3
return (
<div className="flex flex-col h-full bg-surface overflow-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<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>
</div>
<div className="flex items-center gap-2 text-xs text-text-muted">
<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-4 h-4 rounded-sm border ${c}`} />
))}
<span>More</span>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-5 gap-3 mb-6">
{[
{ label: 'Total', value: stats.total, color: 'text-text-primary' },
{ label: 'Upcoming', value: stats.upcoming, color: 'text-blue-400' },
{ label: 'In Progress', value: stats.in_progress, color: 'text-amber-400' },
{ label: 'Completed', value: stats.completed, color: 'text-green-400' },
{ label: 'Overdue', value: stats.overdue, color: 'text-red-400' },
].map(({ label, value, color }) => (
<div key={label} 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-text-muted text-xs mt-1">{label}</p>
</div>
))}
</div>
{/* Heatmap grid */}
<div className="flex gap-3 overflow-x-auto pb-4">
{/* 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 }} className="flex items-center text-[10px] text-text-muted/50 font-mono w-4">{d}</div>
))}
</div>
{/* Grid */}
<div className="flex flex-col flex-shrink-0">
{/* Month labels */}
<div className="relative h-5 mb-1">
{monthLabels.map(({ wi, label }) => (
<span key={label+wi} className="absolute text-[10px] text-text-muted/60 font-medium"
style={{ left: wi * (CELL + GAP) }}>
{label}
</span>
))}
</div>
{/* Week columns */}
<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 }) => (
<div key={key}
style={{ width: CELL, height: CELL }}
className={`rounded-sm border cursor-pointer transition-all hover:scale-125 hover:z-10 relative
${getCellStyle(items.length)}
${isToday(date) ? 'ring-1 ring-white/40' : ''}
`}
onClick={() => items.length > 0 && openFocus(items[0].project.id, items[0].deliverable.id)}
onMouseEnter={(e) => setTooltip({ x: e.clientX, y: e.clientY, date, items })}
onMouseLeave={() => setTooltip(null)}
/>
))}
</div>
))}
</div>
</div>
</div>
{/* 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]"
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 290), top: Math.max(tooltip.y - 100, 8) }}>
<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')}
</p>
{tooltip.items.length === 0 ? (
<p className="text-text-muted/50 text-xs">No deliverables</p>
) : (
<div className="space-y-1.5">
{tooltip.items.slice(0, 5).map(({ deliverable, project }) => (
<div key={deliverable.id} className="flex items-start gap-1.5">
<div className="w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0" style={{ backgroundColor: project.color }} />
<div className="min-w-0 flex-1">
<p className="text-[11px] text-text-primary truncate">{deliverable.title}</p>
<p className="text-[10px] text-text-muted/60">{project.name}</p>
</div>
</div>
))}
{tooltip.items.length > 5 && (
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
)}
</div>
)}
</div>
)}
</div>
)
}