Compare commits

...

10 Commits

Author SHA1 Message Date
jasonMPM
bdd29b1d41 Merge pull request #38 from jasonMPM/pr2
Add files via upload
2026-03-06 00:39:03 -06:00
jasonMPM
bdbdff7842 Add files via upload 2026-03-06 00:38:31 -06:00
jasonMPM
cdc1ad5da9 Merge pull request #37 from jasonMPM/pr2
Add files via upload
2026-03-06 00:33:12 -06:00
jasonMPM
72c5ebf312 Add files via upload 2026-03-06 00:32:38 -06:00
jasonMPM
c68d7a4cc0 Merge pull request #36 from jasonMPM/pr2
Add files via upload
2026-03-06 00:28:55 -06:00
jasonMPM
3e21125a6b Add files via upload 2026-03-06 00:28:33 -06:00
jasonMPM
faab3d52f3 Merge pull request #35 from jasonMPM/pr2
Add files via upload
2026-03-06 00:19:41 -06:00
jasonMPM
748acc41c3 Add files via upload 2026-03-06 00:19:24 -06:00
jasonMPM
f8dc6563b8 Merge pull request #34 from jasonMPM/update
Add files via upload
2026-03-06 00:03:55 -06:00
jasonMPM
59619c4ed1 Add files via upload 2026-03-06 00:03:06 -06:00
12 changed files with 615 additions and 300 deletions

View File

@@ -1,9 +1,12 @@
import os import os
from flask import Flask, send_from_directory from flask import Flask, send_from_directory
from sqlalchemy import text from sqlalchemy import text
from .extensions import db, migrate, cors from .extensions import db, migrate, cors
from config import config from config import config
def create_app(config_name=None): def create_app(config_name=None):
if config_name is None: if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'production') config_name = os.environ.get('FLASK_ENV', 'production')
@@ -17,6 +20,7 @@ def create_app(config_name=None):
from .routes.projects import projects_bp from .routes.projects import projects_bp
from .routes.deliverables import deliverables_bp from .routes.deliverables import deliverables_bp
app.register_blueprint(projects_bp, url_prefix='/api') app.register_blueprint(projects_bp, url_prefix='/api')
app.register_blueprint(deliverables_bp, url_prefix='/api') app.register_blueprint(deliverables_bp, url_prefix='/api')
@@ -43,7 +47,9 @@ def _run_migrations():
""" """
migrations = [ migrations = [
'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)', 'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)',
'ALTER TABLE projects ADD COLUMN archived_at DATETIME',
] ]
with db.engine.connect() as conn: with db.engine.connect() as conn:
for stmt in migrations: for stmt in migrations:
try: try:

View File

@@ -1,6 +1,7 @@
from .extensions import db from .extensions import db
from datetime import datetime, date from datetime import datetime, date
class Project(db.Model): class Project(db.Model):
__tablename__ = 'projects' __tablename__ = 'projects'
@@ -9,11 +10,14 @@ class Project(db.Model):
color = db.Column(db.String(7), nullable=False, default='#C9A84C') color = db.Column(db.String(7), nullable=False, default='#C9A84C')
description = db.Column(db.Text) description = db.Column(db.Text)
drive_url = db.Column(db.String(500)) drive_url = db.Column(db.String(500))
archived_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
deliverables = db.relationship( deliverables = db.relationship(
'Deliverable', backref='project', 'Deliverable',
cascade='all, delete-orphan', lazy=True backref='project',
cascade='all, delete-orphan',
lazy=True,
) )
def to_dict(self, include_deliverables=True): def to_dict(self, include_deliverables=True):
@@ -23,6 +27,7 @@ class Project(db.Model):
'color': self.color, 'color': self.color,
'description': self.description, 'description': self.description,
'drive_url': self.drive_url, 'drive_url': self.drive_url,
'archived_at': self.archived_at.isoformat() if self.archived_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
} }
if include_deliverables: if include_deliverables:
@@ -36,7 +41,11 @@ class Deliverable(db.Model):
__tablename__ = 'deliverables' __tablename__ = 'deliverables'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False) project_id = db.Column(
db.Integer,
db.ForeignKey('projects.id', ondelete='CASCADE'),
nullable=False,
)
title = db.Column(db.String(300), nullable=False) title = db.Column(db.String(300), nullable=False)
due_date = db.Column(db.Date, nullable=False) due_date = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default='upcoming') status = db.Column(db.String(20), nullable=False, default='upcoming')
@@ -44,9 +53,8 @@ class Deliverable(db.Model):
def effective_status(self): def effective_status(self):
""" """
Returns 'overdue' if the due date has passed and the deliverable Returns 'overdue' if the due date has passed and the deliverable has not been
has not been marked completed. Completed deliverables are never marked completed. Completed deliverables are never auto-downgraded.
auto-downgraded regardless of date.
""" """
if self.status != 'completed' and self.due_date < date.today(): if self.status != 'completed' and self.due_date < date.today():
return 'overdue' return 'overdue'

View File

@@ -1,20 +1,23 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from ..models import Project, Deliverable from ..models import Project, Deliverable
from ..extensions import db from ..extensions import db
from datetime import date from datetime import date, datetime
projects_bp = Blueprint('projects', __name__) projects_bp = Blueprint('projects', __name__)
@projects_bp.route('/projects', methods=['GET']) @projects_bp.route('/projects', methods=['GET'])
def get_projects(): def get_projects():
projects = Project.query.order_by(Project.created_at.desc()).all() projects = Project.query.order_by(Project.created_at.desc()).all()
return jsonify([p.to_dict() for p in projects]) return jsonify([p.to_dict() for p in projects])
@projects_bp.route('/projects/<int:id>', methods=['GET']) @projects_bp.route('/projects/<int:id>', methods=['GET'])
def get_project(id): def get_project(id):
project = Project.query.get_or_404(id) project = Project.query.get_or_404(id)
return jsonify(project.to_dict()) return jsonify(project.to_dict())
@projects_bp.route('/projects', methods=['POST']) @projects_bp.route('/projects', methods=['POST'])
def create_project(): def create_project():
data = request.get_json() data = request.get_json()
@@ -26,6 +29,7 @@ def create_project():
) )
db.session.add(project) db.session.add(project)
db.session.flush() db.session.flush()
for d in data.get('deliverables', []): for d in data.get('deliverables', []):
if d.get('title') and d.get('due_date'): if d.get('title') and d.get('due_date'):
db.session.add(Deliverable( db.session.add(Deliverable(
@@ -34,9 +38,11 @@ def create_project():
due_date=date.fromisoformat(d['due_date']), due_date=date.fromisoformat(d['due_date']),
status=d.get('status', 'upcoming'), status=d.get('status', 'upcoming'),
)) ))
db.session.commit() db.session.commit()
return jsonify(project.to_dict()), 201 return jsonify(project.to_dict()), 201
@projects_bp.route('/projects/<int:id>', methods=['PATCH']) @projects_bp.route('/projects/<int:id>', methods=['PATCH'])
def update_project(id): def update_project(id):
project = Project.query.get_or_404(id) project = Project.query.get_or_404(id)
@@ -47,6 +53,23 @@ def update_project(id):
db.session.commit() db.session.commit()
return jsonify(project.to_dict()) return jsonify(project.to_dict())
@projects_bp.route('/projects/<int:id>/archive', methods=['PATCH'])
def archive_project(id):
project = Project.query.get_or_404(id)
project.archived_at = datetime.utcnow()
db.session.commit()
return jsonify(project.to_dict())
@projects_bp.route('/projects/<int:id>/unarchive', methods=['PATCH'])
def unarchive_project(id):
project = Project.query.get_or_404(id)
project.archived_at = None
db.session.commit()
return jsonify(project.to_dict())
@projects_bp.route('/projects/<int:id>', methods=['DELETE']) @projects_bp.route('/projects/<int:id>', methods=['DELETE'])
def delete_project(id): def delete_project(id):
project = Project.query.get_or_404(id) project = Project.query.get_or_404(id)

View File

@@ -1,7 +1,11 @@
import axios from 'axios' import axios from 'axios'
const B = '/api' const B = '/api'
export const fetchProjects = () => axios.get(`${B}/projects`).then(r => r.data) export const fetchProjects = () => axios.get(`${B}/projects`).then(r => r.data)
export const fetchProject = (id) => axios.get(`${B}/projects/${id}`).then(r => r.data) export const fetchProject = (id) => axios.get(`${B}/projects/${id}`).then(r => r.data)
export const createProject = (data) => axios.post(`${B}/projects`, data).then(r => r.data) export const createProject = (data) => axios.post(`${B}/projects`, data).then(r => r.data)
export const updateProject = (id, d) => axios.patch(`${B}/projects/${id}`, d).then(r => r.data) export const updateProject = (id, d) => axios.patch(`${B}/projects/${id}`, d).then(r => r.data)
export const deleteProject = (id) => axios.delete(`${B}/projects/${id}`).then(r => r.data) export const deleteProject = (id) => axios.delete(`${B}/projects/${id}`).then(r => r.data)
export const archiveProject = (id) => axios.patch(`${B}/projects/${id}/archive`).then(r => r.data)
export const unarchiveProject = (id) => axios.patch(`${B}/projects/${id}/unarchive`).then(r => r.data)

View File

@@ -0,0 +1,178 @@
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 live from store so status cycles re-render 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)
}
}
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">
{/* 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) ? '★ 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={() => { jumpToCalendarDate(dateStr); onClose() }}
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"
>
&rarr; 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"
title="Close"
>
&times;
</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">&#9633;</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}>
<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"
>
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<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>
<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 ? '…' : '⟳'}
</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"
>
</button>
</div>
</div>
))}
</div>
</div>
))
)}
</div>
{/* Footer 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">
cycles status &nbsp;&middot;&nbsp; opens Focus View &nbsp;&middot;&nbsp; &rarr; Calendar jumps to date
</p>
</div>
</div>
{/* Add Deliverable modal */}
<DeliverableModal
isOpen={addModal}
onClose={() => setAddModal(false)}
deliverable={null}
defaultDate={dateStr}
/>
</>
)
}

View File

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

View File

@@ -1,50 +1,24 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { format, startOfWeek, addDays, addWeeks, isToday } from 'date-fns' import { format, startOfWeek, addDays, addWeeks, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore' 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 DAY_INIT = ['M','T','W','T','F','S','S']
const CELL = 16 const CELL = 16
const CELL_LG = 40 const CELL_LG = 40
const GAP_LG = 4
const GAP = 2 const GAP = 2
const GAP_LG = 4
const STATUS_KEYS = ['upcoming','in_progress','completed','overdue'] const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
const STATUS_LABEL = { const STATUS_LABEL = { upcoming: 'Upcoming', in_progress: 'In Progress', completed: 'Completed', overdue: 'Overdue' }
upcoming: 'Upcoming', const STATUS_COLOR = { upcoming: 'text-blue-400', in_progress: 'text-amber-400', completed: 'text-green-400', overdue: 'text-red-400' }
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 = { const STATUS_CELL_COLORS = {
upcoming: [ 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)]'],
'bg-blue-400/20 border-blue-400/30', 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)]'],
'bg-blue-400/55 border-blue-400/70', 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)]'],
'bg-blue-400 border-blue-400 shadow-[0_0_4px_rgba(96,165,250,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)]'],
],
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 = { const STATUS_HOVER_RING = {
@@ -54,50 +28,57 @@ const STATUS_HOVER_RING = {
overdue: 'hover:ring-1 hover:ring-red-400/90', 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 } const STATUS_PRIORITY = { overdue: 4, in_progress: 3, upcoming: 2, completed: 1 }
function getCellClass(count, statusKey) { function getCellClass(count, statusKey) {
if (count === 0) return 'bg-surface border-surface-border' if (count === 0) return 'bg-surface border-surface-border'
const colors = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming const c = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
if (count === 1) return colors[0] if (count === 1) return c[0]
if (count === 2) return colors[1] if (count === 2) return c[1]
return colors[2] return c[2]
} }
function getDominantStatus(statusCounts) { function getDominantStatus(statusCounts) {
let dominant = null let dominant = null, maxCount = 0, maxPriority = 0
let maxCount = 0
let maxPriority = 0
for (const [sk, count] of Object.entries(statusCounts)) { for (const [sk, count] of Object.entries(statusCounts)) {
if (count === 0) continue if (count === 0) continue
if ( if (count > maxCount || (count === maxCount && (STATUS_PRIORITY[sk] || 0) > maxPriority)) {
count > maxCount || dominant = sk; maxCount = count; maxPriority = STATUS_PRIORITY[sk] || 0
(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) } return { dominant, total: Object.values(statusCounts).reduce((a, b) => a + b, 0) }
} }
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() { export default function WorkloadHeatmap() {
const projects = useProjectStore(s => s.projects) const projects = useProjectStore(s => s.projects)
const openFocus = useFocusStore(s => s.openFocus)
const [tooltip, setTooltip] = useState(null) const [tooltip, setTooltip] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [weekOffset, setWeekOffset] = useState(0)
const { weeks, stats } = useMemo(() => { 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 = {} const map = {}
projects.forEach(p => { projects.forEach(p => {
(p.deliverables || []).forEach(d => { ;(p.deliverables || []).forEach(d => {
const key = d.due_date const key = d.due_date
if (!map[key]) map[key] = { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } } if (!map[key]) map[key] = { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
map[key].items.push({ deliverable: d, project: p }) 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]++ if (map[key].statusCounts[s] !== undefined) map[key].statusCounts[s]++
}) })
}) })
@@ -120,7 +101,13 @@ export default function WorkloadHeatmap() {
overdue: all.filter(d => d.status === 'overdue').length, overdue: all.filter(d => d.status === 'overdue').length,
} }
return { weeks: grid, stats } 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')} ${format(windowEnd, 'MMM d, yyyy')}`
: ''
const monthLabels = useMemo(() => { const monthLabels = useMemo(() => {
const labels = []; let last = -1 const labels = []; let last = -1
@@ -142,14 +129,37 @@ export default function WorkloadHeatmap() {
return ( return (
<div className="flex flex-col h-full bg-surface overflow-auto"> <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 + window navigation */}
<div className="flex items-center justify-between pl-24 pr-8 pt-6 pb-3">
<div> <div>
<h2 className="text-gold font-bold text-lg tracking-wide">Workload Heatmap</h2> <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> <p className="text-text-muted text-xs mt-0.5">20 weeks of deliverable density by status</p>
</div> </div>
<div className="flex items-center gap-3 text-[10px] text-text-muted"> <div className="flex items-center gap-2">
<span className="uppercase tracking-[0.18em] text-text-muted/60">FABDASH</span> <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"
>&larr; 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 &rarr;</button>
<span className="text-[10px] text-text-muted/40 font-mono ml-1 hidden xl:block">{windowLabel}</span>
</div> </div>
</div> </div>
@@ -161,7 +171,11 @@ export default function WorkloadHeatmap() {
<p className={`text-2xl font-bold ${STATUS_COLOR[statusKey]}`}>{stats[statusKey]}</p> <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> <p className="text-text-muted text-xs mt-1">{STATUS_LABEL[statusKey]}</p>
</div> </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="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"> <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 }}> <div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
{DAY_INIT.map((d, i) => ( {DAY_INIT.map((d, i) => (
@@ -183,16 +197,12 @@ export default function WorkloadHeatmap() {
<div <div
key={key + statusKey} key={key + statusKey}
style={{ width: CELL, height: CELL }} style={{ width: CELL, height: CELL }}
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
${getCellClass(count, statusKey)} ${getCellClass(count, statusKey)}
${isToday(date) ? 'ring-1 ring-white/60' : ''} ${isToday(date) ? 'ring-1 ring-white/60' : ''}
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''} ${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
`} `}
onClick={() => { onClick={() => setSelectedDay(date)}
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) => { onMouseEnter={(e) => {
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey) const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
if (!filtered.length) return if (!filtered.length) return
@@ -209,10 +219,11 @@ export default function WorkloadHeatmap() {
</div> </div>
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
{/* Combined heatmap */} {/* Combined All Tasks heatmap */}
<div className="px-8 pb-8"> <div className="px-8 pb-8">
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-5"> <div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-5">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -227,19 +238,15 @@ export default function WorkloadHeatmap() {
<span className={STATUS_COLOR[sk]}>{STATUS_LABEL[sk]}</span> <span className={STATUS_COLOR[sk]}>{STATUS_LABEL[sk]}</span>
</div> </div>
))} ))}
<span className="ml-1 text-text-muted/40">· color = highest count</span> <span className="ml-1 text-text-muted/40">&middot; color = highest priority</span>
</div> </div>
</div> </div>
<div className="flex gap-3 overflow-x-auto pb-2 justify-center"> <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 }}> <div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
{DAY_INIT.map((d, i) => ( {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 key={i} style={{ height: CELL_LG }} className="flex items-center text-[11px] text-text-muted/50 font-mono w-4">{d}</div>
))} ))}
</div> </div>
{/* Grid */}
<div className="flex flex-col flex-shrink-0"> <div className="flex flex-col flex-shrink-0">
<div className="relative h-5 mb-1"> <div className="relative h-5 mb-1">
{monthLabelsBig.map(({ wi, label }) => ( {monthLabelsBig.map(({ wi, label }) => (
@@ -255,26 +262,15 @@ export default function WorkloadHeatmap() {
<div <div
key={key + 'combined'} key={key + 'combined'}
style={{ width: CELL_LG, height: CELL_LG }} 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'} ${dominant ? getCellClass(total, dominant) : 'bg-surface border-surface-border'}
${isToday(date) ? 'ring-1 ring-white/60' : ''} ${isToday(date) ? 'ring-1 ring-white/60' : ''}
${dominant ? STATUS_HOVER_RING[dominant] : ''} ${dominant ? STATUS_HOVER_RING[dominant] : ''}
`} `}
onClick={() => { onClick={() => setSelectedDay(date)}
if (!items?.length) return
openFocus(items[0].project.id, items[0].deliverable.id)
}}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!items?.length) return if (!items?.length) return
setTooltip({ setTooltip({ x: e.clientX, y: e.clientY, date, statusKey: dominant, items, combined: true, statusCounts })
x: e.clientX,
y: e.clientY,
date,
statusKey: dominant,
items,
combined: true,
statusCounts,
})
}} }}
onMouseLeave={() => setTooltip(null)} onMouseLeave={() => setTooltip(null)}
/> />
@@ -288,14 +284,14 @@ export default function WorkloadHeatmap() {
</div> </div>
</div> </div>
{/* Tooltip */} {/* Hover tooltip */}
{tooltip && ( {tooltip && (
<div <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]" 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]"
style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 330), top: Math.max(tooltip.y - 100, 8) }} style={{ left: Math.min(tooltip.x + 14, window.innerWidth - 330), top: Math.max(tooltip.y - 100, 8) }}
> >
<p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}> <p className={`text-xs font-bold mb-1.5 ${isToday(tooltip.date) ? 'text-gold' : 'text-text-primary'}`}>
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')} {isToday(tooltip.date) ? 'Today ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
</p> </p>
{tooltip.combined && tooltip.statusCounts ? ( {tooltip.combined && tooltip.statusCounts ? (
<div className="space-y-1 mb-2"> <div className="space-y-1 mb-2">
@@ -308,7 +304,7 @@ export default function WorkloadHeatmap() {
</div> </div>
) : ( ) : (
<p className={`text-[10px] mb-1.5 ${tooltip.statusKey ? STATUS_COLOR[tooltip.statusKey] : 'text-text-muted/60'}`}> <p className={`text-[10px] mb-1.5 ${tooltip.statusKey ? STATUS_COLOR[tooltip.statusKey] : 'text-text-muted/60'}`}>
{tooltip.statusKey ? STATUS_LABEL[tooltip.statusKey] : ''} \u00b7 {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''} {tooltip.statusKey ? STATUS_LABEL[tooltip.statusKey] : ''} &middot; {tooltip.items.length} task{tooltip.items.length !== 1 ? 's' : ''}
</p> </p>
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -325,8 +321,17 @@ export default function WorkloadHeatmap() {
<p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p> <p className="text-[10px] text-text-muted/50 pl-3">+{tooltip.items.length - 5} more</p>
)} )}
</div> </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> </div>
)} )}
{/* Day detail panel */}
{selectedDay && (
<HeatmapDayPanel
date={selectedDay}
onClose={() => setSelectedDay(null)}
/>
)}
</div> </div>
) )
} }

View File

@@ -14,9 +14,15 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setCtxMenu({ setCtxMenu({
x: e.clientX, y: e.clientY, x: e.clientX,
y: e.clientY,
items: [ items: [
{ icon: '✎', label: 'Edit Deliverable', highlight: true, action: () => onEdit(deliverable) }, {
icon: '✎',
label: 'Edit Deliverable',
highlight: true,
action: () => onEdit(deliverable),
},
{ separator: true }, { separator: true },
...STATUS_OPTIONS.map(s => ({ ...STATUS_OPTIONS.map(s => ({
icon: s.value === deliverable.status ? '●' : '○', icon: s.value === deliverable.status ? '●' : '○',
@@ -27,7 +33,9 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
})), })),
{ separator: true }, { separator: true },
{ {
icon: '✕', label: 'Delete Deliverable', danger: true, icon: '✕',
label: 'Delete Deliverable',
danger: true,
action: async () => { action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) { if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverable.id) await deleteDeliverable(deliverable.id)
@@ -46,43 +54,30 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
onDoubleClick={(e) => { e.stopPropagation(); onEdit(deliverable) }} onDoubleClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
title="Click: Select · Double-click: Edit · Right-click: Menu" title="Click: Select · Double-click: Edit · Right-click: Menu"
className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer className={`relative flex flex-col gap-2 rounded-xl border p-4 min-w-[190px] max-w-[230px] flex-shrink-0 cursor-pointer transition-all duration-200 select-none mt-4 ${
transition-all duration-200 select-none mt-4 isActive
${isActive
? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30' ? 'border-gold bg-surface-elevated shadow-gold scale-105 ring-2 ring-gold/30'
: 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60' : 'border-surface-border bg-surface hover:border-gold/40 hover:bg-surface-elevated/60'
}`} }`}
> >
{isActive && ( {isActive && (
<div className="absolute -top-5 left-1/2 -translate-x-1/2 bg-gold text-surface text-[9px] font-black px-2.5 py-0.5 rounded-full tracking-widest uppercase whitespace-nowrap"> <div className="absolute -top-5 left-1/2 -translate-x-1/2 text-[9px] font-bold tracking-widest uppercase text-gold/80 whitespace-nowrap">
Selected Selected
</div> </div>
)} )}
<div className="flex items-center gap-1.5"> <div className="text-[10px] text-text-muted/50 font-mono">Deliverable {index + 1}</div>
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} /> <div className="text-sm font-semibold text-text-primary leading-snug line-clamp-3">{deliverable.title}</div>
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}> <div className="text-xs text-text-muted/70 mt-auto pt-1">{formatDate(deliverable.due_date)}</div>
Deliverable {index + 1}
</span>
</div>
<p className={`text-sm font-semibold leading-snug ${isActive ? 'text-text-primary' : 'text-text-muted'}`}>
{deliverable.title}
</p>
<p className={`text-xs font-mono ${isActive ? 'text-gold' : 'text-text-muted/50'}`}>
{formatDate(deliverable.due_date)}
</p>
<Badge status={deliverable.status} /> <Badge status={deliverable.status} />
{isActive && (
<button
onClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
className="mt-1 text-[10px] text-gold/70 hover:text-gold border border-gold/20 hover:border-gold/50 rounded px-2 py-0.5 transition-all text-center"
>
Edit
</button>
)}
</div> </div>
{ctxMenu && ( {ctxMenu && (
<ContextMenu x={ctxMenu.x} y={ctxMenu.y} items={ctxMenu.items} onClose={() => setCtxMenu(null)} /> <ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenu.items}
onClose={() => setCtxMenu(null)}
/>
)} )}
</> </>
) )

View File

@@ -4,25 +4,21 @@ import { formatDate } from '../../utils/dateHelpers'
import useFocusStore from '../../store/useFocusStore' import useFocusStore from '../../store/useFocusStore'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { deleteDeliverable } from '../../api/deliverables' import { deleteDeliverable } from '../../api/deliverables'
import { archiveProject, unarchiveProject } from '../../api/projects'
import DeliverableModal from '../Deliverables/DeliverableModal' import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
function DriveIcon() { function DriveIcon() {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" className="w-3.5 h-3.5"> <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor">
<path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/> <path d="M4.43 20.933l2.893-5.01H22l-2.893 5.01H4.43zm1.55-5.01L2.55 10.08 9.163 2h4.903l-6.613 8.08 3.043 5.843H5.98zm8.753 0l-3.043-5.843L17.987 2H22l-4.297 6.89 3.043 5.843h-6.013z"/>
<path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/>
<path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/>
<path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/>
<path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/>
<path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 27h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/>
</svg> </svg>
) )
} }
export default function ProjectCard({ project, onEdit, onDelete }) { export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) {
const openFocus = useFocusStore(s => s.openFocus) const openFocus = useFocusStore(s => s.openFocus)
const { removeDeliverable } = useProjectStore() const { removeDeliverable, updateProject } = useProjectStore()
const [delModal, setDelModal] = useState({ open: false, deliverable: null }) const [delModal, setDelModal] = useState({ open: false, deliverable: null })
const [ctxMenu, setCtxMenu] = useState(null) const [ctxMenu, setCtxMenu] = useState(null)
@@ -52,11 +48,25 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
const handleHeaderCtx = (e) => { const handleHeaderCtx = (e) => {
e.preventDefault() e.preventDefault()
const isArchived = !!project.archived_at
setCtxMenu({ setCtxMenu({
x: e.clientX, y: e.clientY, x: e.clientX, y: e.clientY,
items: [ items: [
{ icon: '✎', label: 'Edit Project', action: () => onEdit(project) }, { icon: '✎', label: 'Edit Project', action: () => onEdit(project) },
...(project.drive_url ? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }] : []), ...(project.drive_url
? [{ icon: '⬡', label: 'Open Drive', action: () => window.open(project.drive_url, '_blank') }]
: []),
{
icon: isArchived ? '↺' : '⏸',
label: isArchived ? 'Unarchive Project' : 'Archive Project',
action: async () => {
const updated = isArchived
? await unarchiveProject(project.id)
: await archiveProject(project.id)
updateProject(updated)
onArchiveToggle?.()
},
},
{ separator: true }, { separator: true },
{ icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) }, { icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) },
], ],
@@ -64,61 +74,68 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
} }
return ( return (
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20"> <div className={`rounded-xl border bg-surface-raised overflow-hidden ${
<div className="h-1 w-full" style={{ backgroundColor: project.color }} /> project.archived_at ? 'opacity-60 border-surface-border' : 'border-surface-border'
<div className="p-3"> }`}>
{/* Header — double-click to edit, right-click for menu */} {/* Header */}
<div <div
className="flex items-start justify-between mb-1.5 cursor-default" className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-surface-elevated/40 transition-colors"
onDoubleClick={() => onEdit(project)} onDoubleClick={() => onEdit(project)}
onContextMenu={handleHeaderCtx} onContextMenu={handleHeaderCtx}
title="Double-click to edit project" title="Double-click to edit project"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} /> <span className="font-semibold text-sm text-text-primary truncate flex-1">{project.name}</span>
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
</div> {project.archived_at && (
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1"> <span className="text-[9px] font-semibold tracking-widest uppercase text-amber-400/70 bg-amber-400/10 border border-amber-400/20 rounded px-1.5 py-0.5 flex-shrink-0">
Archived
</span>
)}
{project.drive_url && ( {project.drive_url && (
<a href={project.drive_url} target="_blank" rel="noopener noreferrer" <a
title="Open Google Drive folder" onClick={e => e.stopPropagation()} href={project.drive_url}
className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1"> target="_blank"
<DriveIcon /><span>Drive</span> rel="noreferrer"
onClick={e => e.stopPropagation()}
className="flex items-center gap-1 text-[10px] text-text-muted hover:text-text-primary bg-surface hover:bg-surface-border/40 border border-surface-border hover:border-gold/30 rounded px-1.5 py-1 transition-all mr-1"
>
<DriveIcon /> Drive
</a> </a>
)} )}
<button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm" title="Edit project"></button> <button onClick={() => onEdit(project)} className="text-text-muted hover:text-gold p-1 transition-colors text-sm" title="Edit project"></button>
<button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm" title="Delete project"></button> <button onClick={() => onDelete(project)} className="text-text-muted hover:text-red-400 p-1 transition-colors text-sm" title="Delete project"></button>
</div> </div>
</div>
{project.description && ( {project.description && (
<p className="text-xs text-text-muted mb-2 line-clamp-1">{project.description}</p> <p className="text-text-muted text-xs px-3 pb-1.5 -mt-1 truncate">{project.description}</p>
)} )}
{/* Deliverable rows */} {/* Deliverable rows */}
<div className="space-y-1"> <div className="px-2 pb-2 space-y-1">
{(project.deliverables || []).map(d => ( {(project.deliverables || []).map(d => (
<button <button
key={d.id} key={d.id}
onClick={() => openFocus(project.id, d.id)} onClick={() => openFocus(project.id, d.id)}
onDoubleClick={(e) => { e.stopPropagation(); openDelEdit(d) }} onDoubleClick={e => { e.stopPropagation(); openDelEdit(d) }}
onContextMenu={(e) => handleRowCtx(e, d)} onContextMenu={e => handleRowCtx(e, d)}
title="Click: Focus View · Double-click: Edit · Right-click: Menu" title="Click: Focus View · Double-click: Edit · Right-click: Menu"
className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group" className="w-full flex items-center justify-between text-xs bg-surface rounded px-2 py-1.5 border border-transparent hover:border-gold/20 hover:bg-surface-border/20 transition-all text-left group"
> >
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span> <span className="truncate text-text-primary group-hover:text-white transition-colors">{d.title}</span>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</span> <span className="text-text-muted/60 text-[10px]">{formatDate(d.due_date)}</span>
<Badge status={d.status} /> <Badge status={d.status} />
</div> </div>
</button> </button>
))} ))}
{(!project.deliverables || project.deliverables.length === 0) && (
<p className="text-[11px] text-text-muted/40 italic text-center py-1">No deliverables</p>
)}
</div>
{(!project.deliverables || project.deliverables.length === 0) && (
<p className="text-text-muted/40 text-xs px-1 py-1">No deliverables</p>
)}
</div> </div>
{/* Local deliverable edit modal */} {/* Local deliverable edit modal */}

View File

@@ -5,28 +5,58 @@ import Button from '../UI/Button'
import AgendaPanel from '../Calendar/AgendaPanel' import AgendaPanel from '../Calendar/AgendaPanel'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import useUIStore from '../../store/useUIStore' import useUIStore from '../../store/useUIStore'
import { deleteProject } from '../../api/projects' import { deleteProject, fetchProjects } from '../../api/projects'
const VIEW_OPTIONS = [
{ key: 'active', label: 'Active' },
{ key: 'archived', label: 'Archived' },
{ key: 'all', label: 'All' },
]
export default function ProjectList({ onRegisterNewProject }) { export default function ProjectList({ onRegisterNewProject }) {
const { projects, removeProject } = useProjectStore() const { projects, removeProject, setProjects } = useProjectStore()
const { sidebarTab, setSidebarTab } = useUIStore() const { sidebarTab, setSidebarTab } = useUIStore()
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const [projectView, setProjectView] = useState('active')
const [search, setSearch] = useState('')
useEffect(() => { onRegisterNewProject?.(() => setShowModal(true)) }, [onRegisterNewProject]) useEffect(() => {
onRegisterNewProject?.(() => setShowModal(true))
}, [onRegisterNewProject])
const refreshProjects = async () => {
const data = await fetchProjects()
setProjects(data)
}
const handleEdit = (p) => { setEditing(p); setShowModal(true) } const handleEdit = (p) => { setEditing(p); setShowModal(true) }
const handleDelete = async (p) => { const handleDelete = async (p) => {
if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) { if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) {
await deleteProject(p.id); removeProject(p.id) await deleteProject(p.id)
removeProject(p.id)
} }
} }
const handleClose = () => { setShowModal(false); setEditing(null) } const handleClose = () => { setShowModal(false); setEditing(null) }
const q = search.trim().toLowerCase()
const visibleProjects = (projects || [])
.filter(p => {
const isArchived = !!p.archived_at
if (projectView === 'active') return !isArchived
if (projectView === 'archived') return isArchived
return true
})
.filter(p => {
if (!q) return true
const hay = `${p.name || ''} ${p.description || ''}`.toLowerCase()
return hay.includes(q)
})
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — taller to give logo more presence */} {/* Header */}
<div className="flex items-center gap-3 px-4 py-4 border-b border-surface-border flex-shrink-0 pl-10"> <div className="flex items-center gap-3 px-4 py-4 border-b border-surface-border flex-shrink-0 pl-10">
<img <img
src="/logo.png" src="/logo.png"
@@ -48,7 +78,11 @@ export default function ProjectList({ onRegisterNewProject }) {
<div className="flex border-b border-surface-border flex-shrink-0"> <div className="flex border-b border-surface-border flex-shrink-0">
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => ( {[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
<button key={key} onClick={() => setSidebarTab(key)} <button key={key} onClick={() => setSidebarTab(key)}
className={`flex-1 py-2 text-xs font-semibold transition-colors ${sidebarTab === key ? 'text-gold border-b-2 border-gold' : 'text-text-muted hover:text-text-primary'}`}> className={`flex-1 py-2 text-xs font-semibold transition-colors ${
sidebarTab === key
? 'text-gold border-b-2 border-gold'
: 'text-text-muted hover:text-text-primary'
}`}>
{label} {label}
</button> </button>
))} ))}
@@ -58,7 +92,41 @@ export default function ProjectList({ onRegisterNewProject }) {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{sidebarTab === 'projects' ? ( {sidebarTab === 'projects' ? (
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{projects.length === 0 ? (
{/* View toggle + Search + Refresh */}
<div className="flex items-center gap-2 pb-1">
<div className="flex bg-surface-elevated border border-surface-border rounded-lg overflow-hidden flex-shrink-0">
{VIEW_OPTIONS.map(v => (
<button
key={v.key}
onClick={() => setProjectView(v.key)}
className={`px-2.5 py-1.5 text-[10px] font-semibold transition-colors ${
projectView === v.key
? 'bg-gold text-surface'
: 'text-text-muted hover:text-text-primary'
}`}
title={`Show ${v.label.toLowerCase()} projects`}
>
{v.label}
</button>
))}
</div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search projects…"
className="flex-1 min-w-0 bg-surface-elevated border border-surface-border rounded-lg px-2.5 py-1.5 text-xs text-text-primary placeholder:text-text-muted/50 outline-none focus:border-gold/40 transition-colors"
/>
<button
onClick={refreshProjects}
className="flex-shrink-0 text-[10px] px-2 py-1.5 rounded-lg border border-surface-border bg-surface-elevated text-text-muted hover:text-text-primary hover:border-gold/30 transition-colors"
title="Refresh projects"
>
</button>
</div>
{visibleProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center"> <div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<div className="opacity-20 mb-3"> <div className="opacity-20 mb-3">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none"> <svg width="56" height="56" viewBox="0 0 56 56" fill="none">
@@ -70,14 +138,16 @@ export default function ProjectList({ onRegisterNewProject }) {
<line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/> <line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
</svg> </svg>
</div> </div>
<p className="text-text-muted text-sm font-medium">No projects yet</p> <p className="text-text-muted text-sm font-medium">
{q ? 'No matching projects' : projectView === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-text-muted/50 text-xs mt-1"> <p className="text-text-muted/50 text-xs mt-1">
Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project
</p> </p>
</div> </div>
) : ( ) : (
projects.map(p => ( visibleProjects.map(p => (
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} /> <ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} onArchiveToggle={refreshProjects} />
)) ))
)} )}
</div> </div>

View File

@@ -1,11 +1,16 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function ContextMenu({ x, y, items, onClose }) { export default function ContextMenu({ x, y, items, onClose }) {
const ref = useRef(null) const ref = useRef(null)
useEffect(() => { useEffect(() => {
const onMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() } const onMouseDown = (e) => {
const onKey = (e) => { if (e.key === 'Escape') onClose() } if (ref.current && !ref.current.contains(e.target)) onClose()
}
const onKey = (e) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', onMouseDown) document.addEventListener('mousedown', onMouseDown)
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => { return () => {
@@ -20,36 +25,36 @@ export default function ContextMenu({ x, y, items, onClose }) {
const adjX = Math.min(x, window.innerWidth - W - 8) const adjX = Math.min(x, window.innerWidth - W - 8)
const adjY = Math.min(y, window.innerHeight - H - 8) const adjY = Math.min(y, window.innerHeight - H - 8)
return ( // Portal to document.body — escapes any CSS transform stacking context
// (e.g. the Drawer slide-in animation uses translateX which traps fixed children)
return createPortal(
<div <div
ref={ref} ref={ref}
style={{ left: adjX, top: adjY }} style={{ position: 'fixed', left: adjX, top: adjY }}
className="fixed z-[200] min-w-[192px] bg-surface-elevated border border-surface-border rounded-xl shadow-2xl py-1.5 overflow-hidden" className="z-[9999] bg-surface-elevated border border-surface-border rounded-xl shadow-2xl py-1.5 min-w-[192px]"
> >
{items.map((item, i) => {items.map((item, i) =>
item.separator ? ( item.separator ? (
<div key={i} className="h-px bg-surface-border my-1 mx-2" /> <div key={i} className="my-1 border-t border-surface-border/60" />
) : ( ) : (
<button <button
key={i} key={i}
disabled={item.disabled} onClick={() => { item.action?.(); onClose() }}
onClick={() => { item.action(); onClose() }} className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
className={`w-full flex items-center gap-2.5 px-3 py-2 text-xs text-left transition-colors disabled:opacity-40 disabled:cursor-not-allowed item.danger ? 'text-red-400 hover:bg-red-500/10' :
${item.danger item.highlight ? 'text-gold hover:bg-gold/10' :
? 'text-red-400 hover:bg-red-500/10' 'text-text-primary hover:bg-surface-border/40'
: item.highlight
? 'text-gold hover:bg-gold/10'
: 'text-text-primary hover:bg-surface-border/40'
}`} }`}
> >
<span className="text-sm w-4 text-center leading-none flex-shrink-0">{item.icon}</span> <span className="text-[11px] opacity-70 w-3.5 flex-shrink-0">{item.icon}</span>
<span>{item.label}</span> <span className="flex-1">{item.label}</span>
{item.shortcut && ( {item.shortcut && (
<span className="ml-auto text-text-muted/50 text-[10px]">{item.shortcut}</span> <span className="text-[9px] text-text-muted/40 font-mono ml-2">{item.shortcut}</span>
)} )}
</button> </button>
) )
)} )}
</div> </div>,
document.body
) )
} }

View File

@@ -1,13 +1,17 @@
import { create } from 'zustand' import { create } from 'zustand'
const useUIStore = create((set) => ({ export default create((set) => ({
sidebarOpen: true, sidebarOpen: true,
sidebarTab: 'projects', // 'projects' | 'agenda'
showHeatmap: false,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })), toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setSidebarTab: (tab) => set({ sidebarTab: tab }),
toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
}))
export default useUIStore 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 }),
}))