diff --git a/backend/app/models.py b/backend/app/models.py index cedd5c4..a600007 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,5 @@ from .extensions import db -from datetime import datetime +from datetime import datetime, date class Project(db.Model): __tablename__ = 'projects' @@ -42,12 +42,22 @@ class Deliverable(db.Model): status = db.Column(db.String(20), nullable=False, default='upcoming') created_at = db.Column(db.DateTime, default=datetime.utcnow) + def effective_status(self): + """ + Returns 'overdue' if the due date has passed and the deliverable + has not been marked completed. Completed deliverables are never + auto-downgraded regardless of date. + """ + if self.status != 'completed' and self.due_date < date.today(): + return 'overdue' + return self.status + def to_dict(self): return { 'id': self.id, 'project_id': self.project_id, 'title': self.title, 'due_date': self.due_date.isoformat() if self.due_date else None, - 'status': self.status, + 'status': self.effective_status(), 'created_at': self.created_at.isoformat() if self.created_at else None, } diff --git a/frontend/src/components/Calendar/MainCalendar.jsx b/frontend/src/components/Calendar/MainCalendar.jsx index 636aeef..2e90d98 100644 --- a/frontend/src/components/Calendar/MainCalendar.jsx +++ b/frontend/src/components/Calendar/MainCalendar.jsx @@ -1 +1,183 @@ -// ...file truncated in this snippet — only the toolbar area is shown changed +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({ onCalendarReady }) { + const calRef = 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 [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 => ({ + id: String(d.id), + title: `${p.name}: ${d.title}`, + start: d.due_date, + allDay: true, + backgroundColor: p.color, + borderColor: p.color, + extendedProps: { deliverableId: d.id, projectId: p.id }, + })) + ) + + const getCtx = (projectId, deliverableId) => { + const project = projects.find(p => p.id === projectId) + const deliverable = project?.deliverables.find(d => d.id === deliverableId) + return { project, deliverable } + } + + const handleEventClick = useCallback(({ event }) => { + const { deliverableId, projectId } = event.extendedProps + openFocus(projectId, deliverableId) + }, [openFocus]) + + // Drag-and-drop with 30-second undo toast + const handleEventDrop = useCallback(async ({ event, oldEvent }) => { + const { deliverableId } = event.extendedProps + 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 — 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 + + 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() + setTooltip(null) + const { deliverable } = getCtx(projectId, deliverableId) + if (deliverable) setModal({ open: true, deliverable, defaultDate: '' }) + }) + + el.addEventListener('contextmenu', (e) => { + 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') }] : []), + { separator: true }, + { icon: '✕', label: 'Delete Deliverable', danger: true, + action: async () => { + if (window.confirm(`Delete "${deliverable.title}"?`)) { + await deleteDeliverable(deliverableId); removeDeliverable(deliverableId) + } + } + }, + ], + }) + }) + }, [projects, openFocus]) + + return ( +
e.preventDefault()}> + {/* View toggle toolbar */} +
+ +
+ + {/* Main content area */} +
+ {showHeatmap ? ( + + ) : ( +
+ +
+ )} +
+ + setModal({ open: false, deliverable: null, defaultDate: '' })} + deliverable={modal.deliverable} + defaultDate={modal.defaultDate} + /> + + {contextMenu && ( + setContextMenu(null)} /> + )} + + +
+ ) +}