diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e475fc2..7824120 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,12 +1,17 @@
-import { useEffect } from 'react'
+import { useEffect, useRef } from 'react'
import ProjectList from './components/Projects/ProjectList'
import MainCalendar from './components/Calendar/MainCalendar'
import FocusDrawer from './components/FocusView/FocusDrawer'
+import ToastContainer from './components/UI/Toast'
import { fetchProjects } from './api/projects'
import useProjectStore from './store/useProjectStore'
+import useUIStore from './store/useUIStore'
export default function App() {
const { setProjects, setLoading } = useProjectStore()
+ const { sidebarOpen, toggleSidebar } = useUIStore()
+ const calApiRef = useRef(null)
+ const newProjectFn = useRef(null)
useEffect(() => {
setLoading(true)
@@ -15,15 +20,40 @@ export default function App() {
.catch(() => setLoading(false))
}, [])
+ // Global keyboard shortcuts
+ useEffect(() => {
+ const handler = (e) => {
+ if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return
+ if (e.key === 'n' || e.key === 'N') { e.preventDefault(); newProjectFn.current?.() }
+ if (e.key === 'b' || e.key === 'B') toggleSidebar()
+ if (e.key === 'ArrowLeft' && calApiRef.current) calApiRef.current.prev()
+ if (e.key === 'ArrowRight' && calApiRef.current) calApiRef.current.next()
+ if ((e.key === 't' || e.key === 'T') && calApiRef.current) calApiRef.current.today()
+ }
+ document.addEventListener('keydown', handler)
+ return () => document.removeEventListener('keydown', handler)
+ }, [toggleSidebar])
+
return (
e.preventDefault()}>
+
e.preventDefault()}>
+ {/* View toggle toolbar */}
+
+
+
+
+ {/* Main content area */}
-
+ {showHeatmap ? (
+
+ ) : (
+
+
+
+ )}
{contextMenu && (
-
setContextMenu(null)}
- />
+ setContextMenu(null)} />
)}
+
+
)
}
diff --git a/frontend/src/components/Calendar/WorkloadHeatmap.jsx b/frontend/src/components/Calendar/WorkloadHeatmap.jsx
new file mode 100644
index 0000000..3cd383f
--- /dev/null
+++ b/frontend/src/components/Calendar/WorkloadHeatmap.jsx
@@ -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 (
+
+ {/* Header */}
+
+
+
Workload Heatmap
+
{WEEKS} weeks of deliverable density
+
+
+
Less
+ {['bg-surface border-surface-border','bg-gold/25 border-gold/40','bg-gold/55 border-gold/70','bg-gold border-gold'].map((c,i) => (
+
+ ))}
+
More
+
+
+
+ {/* Stat cards */}
+
+ {[
+ { 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 }) => (
+
+ ))}
+
+
+ {/* Heatmap grid */}
+
+ {/* Day labels */}
+
+ {DAY_INIT.map((d, i) => (
+
{d}
+ ))}
+
+
+ {/* Grid */}
+
+ {/* Month labels */}
+
+ {monthLabels.map(({ wi, label }) => (
+
+ {label}
+
+ ))}
+
+ {/* Week columns */}
+
+ {weeks.map((week, wi) => (
+
+ {week.map(({ date, key, items }) => (
+
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)}
+ />
+ ))}
+
+ ))}
+
+
+
+
+ {/* Tooltip */}
+ {tooltip && (
+
+
+ {isToday(tooltip.date) ? 'Today — ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
+
+ {tooltip.items.length === 0 ? (
+
No deliverables
+ ) : (
+
+ {tooltip.items.slice(0, 5).map(({ deliverable, project }) => (
+
+
+
+
{deliverable.title}
+
{project.name}
+
+
+ ))}
+ {tooltip.items.length > 5 && (
+
+{tooltip.items.length - 5} more
+ )}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Projects/ProjectList.jsx b/frontend/src/components/Projects/ProjectList.jsx
index dc7af49..a7be12a 100644
--- a/frontend/src/components/Projects/ProjectList.jsx
+++ b/frontend/src/components/Projects/ProjectList.jsx
@@ -1,14 +1,19 @@
-import { useState } from 'react'
+import { useState, useEffect } from 'react'
import ProjectCard from './ProjectCard'
import ProjectModal from './ProjectModal'
import Button from '../UI/Button'
+import AgendaPanel from '../Calendar/AgendaPanel'
import useProjectStore from '../../store/useProjectStore'
+import useUIStore from '../../store/useUIStore'
import { deleteProject } from '../../api/projects'
-export default function ProjectList() {
- const { projects, removeProject } = useProjectStore()
- const [showModal, setShowModal] = useState(false)
- const [editing, setEditing] = useState(null)
+export default function ProjectList({ onRegisterNewProject }) {
+ const { projects, removeProject } = useProjectStore()
+ const { sidebarTab, setSidebarTab } = useUIStore()
+ const [showModal, setShowModal] = useState(false)
+ const [editing, setEditing] = useState(null)
+
+ useEffect(() => { onRegisterNewProject?.(() => setShowModal(true)) }, [onRegisterNewProject])
const handleEdit = (p) => { setEditing(p); setShowModal(true) }
const handleDelete = async (p) => {
@@ -20,21 +25,64 @@ export default function ProjectList() {
return (
+ {/* Header */}
FabDash
-
- {projects.length === 0 && (
-
-
No projects yet.
-
Click "+ Project" to begin.
-
- )}
- {projects.map(p => (
-
+
+ {/* Tab toggle */}
+
+ {[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
+
))}
+
+ {/* Content */}
+
+ {sidebarTab === 'projects' ? (
+
+ {projects.length === 0 ? (
+
+
+
+
+
No projects yet
+
+ Press N or click + Project
+
+
+ ) : (
+ projects.map(p => (
+
+ ))
+ )}
+
+ ) : (
+
+ )}
+
+
+ {/* Keyboard shortcuts legend */}
+
+ {[['N','New project'],['B','Toggle sidebar'],['←→','Navigate'],['T','Today']].map(([key, desc]) => (
+
+ {key}
+ {desc}
+
+ ))}
+
+
)
diff --git a/frontend/src/components/UI/Modal.jsx b/frontend/src/components/UI/Modal.jsx
index dd2c982..2929d89 100644
--- a/frontend/src/components/UI/Modal.jsx
+++ b/frontend/src/components/UI/Modal.jsx
@@ -1,16 +1,28 @@
-import { useEffect } from 'react'
-export default function Modal({ isOpen, onClose, title, children, size='md' }) {
+import { useEffect, useState } from 'react'
+
+export default function Modal({ isOpen, onClose, title, children, size = 'md' }) {
+ const [visible, setVisible] = useState(false)
+
+ useEffect(() => {
+ if (isOpen) requestAnimationFrame(() => setVisible(true))
+ else setVisible(false)
+ }, [isOpen])
+
useEffect(() => {
const h = (e) => { if (e.key === 'Escape') onClose() }
if (isOpen) document.addEventListener('keydown', h)
return () => document.removeEventListener('keydown', h)
}, [isOpen, onClose])
+
if (!isOpen) return null
- const sizes = { sm:'max-w-md', md:'max-w-lg', lg:'max-w-2xl', xl:'max-w-4xl' }
+ const sizes = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' }
+
return (
-
-
+
+
{title}
diff --git a/frontend/src/components/UI/Toast.jsx b/frontend/src/components/UI/Toast.jsx
new file mode 100644
index 0000000..f1c8fda
--- /dev/null
+++ b/frontend/src/components/UI/Toast.jsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react'
+import useToastStore from '../../store/useToastStore'
+
+function ToastItem({ toast }) {
+ const removeToast = useToastStore(s => s.removeToast)
+ const [secs, setSecs] = useState(toast.duration)
+
+ useEffect(() => {
+ const t = setInterval(() => setSecs(s => {
+ if (s <= 1) { clearInterval(t); removeToast(toast.id); return 0 }
+ return s - 1
+ }), 1000)
+ return () => clearInterval(t)
+ }, [])
+
+ return (
+
+
{toast.message}
+ {toast.undoFn && (
+
+ )}
+
+
+
+ {secs}
+
+
+
+
+ )
+}
+
+export default function ToastContainer() {
+ const toasts = useToastStore(s => s.toasts)
+ if (!toasts.length) return null
+ return (
+
+ {toasts.map(t => (
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/store/useToastStore.js b/frontend/src/store/useToastStore.js
new file mode 100644
index 0000000..9ff88a0
--- /dev/null
+++ b/frontend/src/store/useToastStore.js
@@ -0,0 +1,15 @@
+import { create } from 'zustand'
+
+let _id = 0
+
+const useToastStore = create((set) => ({
+ toasts: [],
+ addToast: ({ message, undoFn, duration = 30 }) => {
+ const id = ++_id
+ set(s => ({ toasts: [...s.toasts, { id, message, undoFn, duration }] }))
+ return id
+ },
+ removeToast: (id) => set(s => ({ toasts: s.toasts.filter(t => t.id !== id) })),
+}))
+
+export default useToastStore
diff --git a/frontend/src/store/useUIStore.js b/frontend/src/store/useUIStore.js
new file mode 100644
index 0000000..8addc01
--- /dev/null
+++ b/frontend/src/store/useUIStore.js
@@ -0,0 +1,13 @@
+import { create } from 'zustand'
+
+const useUIStore = create((set) => ({
+ sidebarOpen: true,
+ sidebarTab: 'projects', // 'projects' | 'agenda'
+ showHeatmap: false,
+
+ toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
+ setSidebarTab: (tab) => set({ sidebarTab: tab }),
+ toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
+}))
+
+export default useUIStore
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css
index cd689e1..afb6a5b 100644
--- a/frontend/src/styles/globals.css
+++ b/frontend/src/styles/globals.css
@@ -8,12 +8,17 @@ body {
background-color: #111111;
color: #F5F5F5;
font-family: 'Inter', system-ui, sans-serif;
- margin: 0;
- padding: 0;
- overflow: hidden;
+ margin: 0; padding: 0; overflow: hidden;
}
-/* ── FullCalendar dark theme overrides ── */
+/* ── Animations ── */
+@keyframes slide-up {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+.animate-slide-up { animation: slide-up 0.2s ease-out forwards; }
+
+/* ── FullCalendar dark theme ── */
.fc {
--fc-border-color: #2E2E2E;
--fc-button-bg-color: #1A1A1A;
@@ -27,7 +32,6 @@ body {
--fc-neutral-bg-color: #1A1A1A;
--fc-event-border-color: transparent;
}
-
.fc-theme-standard td,
.fc-theme-standard th,
.fc-theme-standard .fc-scrollgrid { border-color: #2E2E2E !important; }
@@ -47,27 +51,30 @@ body {
}
.fc-daygrid-event {
- border-radius: 4px !important;
- font-size: 0.72rem !important;
- font-weight: 600 !important;
- cursor: pointer !important;
- padding: 1px 5px !important;
+ border-radius: 4px !important; font-size: 0.72rem !important;
+ font-weight: 600 !important; cursor: pointer !important; padding: 1px 5px !important;
}
-
.fc-event-title { color: #111111 !important; font-weight: 700 !important; }
.fc-day-today .fc-daygrid-day-number {
- background-color: #C9A84C !important;
- color: #111111 !important;
- border-radius: 50% !important;
- width: 26px !important;
- height: 26px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
+ background-color: #C9A84C !important; color: #111111 !important;
+ border-radius: 50% !important; width: 26px !important; height: 26px !important;
+ display: flex !important; align-items: center !important; justify-content: center !important;
}
-/* ── Scrollbar ── */
+/* Week number column */
+.fc-timegrid-axis.fc-scrollgrid-shrink,
+.fc-daygrid-week-number {
+ color: #C9A84C !important;
+ font-size: 0.65rem !important;
+ opacity: 0.6;
+ text-decoration: none !important;
+}
+
+/* Selection highlight */
+.fc-highlight { background: rgba(201, 168, 76, 0.12) !important; }
+
+/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: #1A1A1A; }
::-webkit-scrollbar-thumb { background: #2E2E2E; border-radius: 3px; }