Compare commits

...

17 Commits

Author SHA1 Message Date
fe4d8b120c Merge pull request 'more fixes' (#3) from updates into main
Reviewed-on: #3
2026-03-12 10:36:22 -05:00
jason
ff0730b6c6 more fixes 2026-03-12 10:36:02 -05:00
0d0b71e749 Merge pull request 'fixes' (#2) from updates into main
Reviewed-on: #2
2026-03-12 10:28:53 -05:00
jason
1b8e0367f6 fixes 2026-03-12 10:28:37 -05:00
335879a4b7 Merge pull request 'updates, back and front' (#1) from updates into main
Reviewed-on: #1
2026-03-12 10:23:40 -05:00
jason
a1f8c90801 updates, back and front 2026-03-12 10:23:22 -05:00
jason
03ee3c542e updates and opti 2026-03-12 10:12:48 -05:00
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
17 changed files with 816 additions and 367 deletions

66
ROADMAP.md Normal file
View File

@@ -0,0 +1,66 @@
# FabDash Roadmap
A sleek, modern fabrication dashboard focused on simplicity and clarity.
## Current Features (v1.1)
### Core Management
- **Multi-Deliverable Projects**: Unlimited deliverables per project with customized colors and descriptions.
- **Project Archiving**: Move completed projects out of view without deleting data.
- **Google Drive Integration**: Optional per-project folder links available in sidebars and context menus.
### Visual Scheduling
- **FullCalendar Views**: Month, Week, and Day interactive grids.
- **Drag-and-Drop**: Reschedule deliverables directly on the calendar with instant backend syncing.
- **Undo Actions**: 30-second "Undo" toast with a countdown ring after rescheduling.
- **ISO Week Numbers**: Displayed for better alignment with manufacturing schedules.
### Enhanced Visualization
- **Workload Heatmap**:
- 4 per-status color-coded mini grids (Blue/Amber/Green/Red) with individual stat cards.
- Combined "All Tasks" heatmap with dominant-status coloring.
- Per-day status breakdown tooltips.
- **Focus View**: A persistent timeline drawer at the bottom for deep-diving into a project's chronology.
- **Event Tooltips**: Hover for quick deliverable/project info without leaving the calendar.
### Performance & UX
- **Smooth Reflows**: `ResizeObserver` ensures the calendar scales perfectly as the sidebar toggles.
- **Keyboard Shortcuts**: `N` (New Project), `B` (Toggle Sidebar), `T` (Today), `Arrows` (Navigation).
- **Custom Branding**: Easy square logo swapping at `frontend/public/logo.png`.
- **Public Landing Page**: Standalone `/landing.html` for marketing and quick-start.
---
## Proposed Features
### v1.2 — Polish & Productivity
- [ ] **Mobile Responsiveness**: Adaptive layouts for phones and tablets.
- [ ] **Print/Export to PDF**: Generate clean, printable snapshots of the calendar and agenda.
- [ ] **Bulk Status Selection**: Shift multiple deliverables to a new status from the heatmap or sidebar.
- [ ] **Animated Transitions**: Smooth exit animations for modals and drawers.
- [ ] **Mini-Calendar**: A small date picker in the sidebar for rapid navigation.
### v2.0 — Security & Multi-User
- [ ] **User Authentication**: Flask-Login + JWT integration for secure access.
- [ ] **Multi-User Support**: Individual accounts with project ownership.
- [x] **API Service Layer Abstraction**: Consolidate API interaction logic into Zustand actions.
- [ ] **Role-Based Access (RBAC)**: Define Owners, Editors, and Viewers per project.
- [ ] **Audit Logs**: Track who changed what and when.
### v2.1 — Connectivity & Notifications
- [ ] **Automated Reminders**: Email notifications for upcoming or overdue tasks.
- [ ] **Third-Party Integration**: iCal/Google Calendar feeds and Slack webhooks for status changes.
- [ ] **CSV Import/Export**: Bulk import capability for project migration.
### v3.0 — Intelligent Scheduling
- [ ] **AI-Assisted Scheduling**: Automated suggestions based on current workload.
- [ ] **Conflict Detection**: Visual warnings when days are overloaded.
- [ ] **Natural Language Entry**: "Add 'Cut Plates' to Tucson next Friday" via a command bar.
---
## Technical Debt & Growth
- [ ] **Test Coverage**: implement backend (pytest) and frontend (Vitest) test suites.
- [ ] **Migration Framework**: Switch to `Flask-Migrate` for formal schema versioning.
- [ ] **Structured Logging**: Centralized backend logs for better monitoring.
- [x] **Global Exception Handling**: Standardized API error responses.

View File

@@ -1,9 +1,13 @@
import os
from flask import Flask, send_from_directory
from flask import Flask, send_from_directory, jsonify
from werkzeug.exceptions import HTTPException, NotFound, BadRequest
from sqlalchemy import text
from .extensions import db, migrate, cors
from config import config
def create_app(config_name=None):
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'production')
@@ -17,6 +21,7 @@ def create_app(config_name=None):
from .routes.projects import projects_bp
from .routes.deliverables import deliverables_bp
app.register_blueprint(projects_bp, url_prefix='/api')
app.register_blueprint(deliverables_bp, url_prefix='/api')
@@ -24,13 +29,26 @@ def create_app(config_name=None):
db.create_all()
_run_migrations()
@app.route('/', defaults={'path': ''})
@app.route('/<path:path>')
def serve_react(path):
static_folder = app.static_folder
if path and os.path.exists(os.path.join(static_folder, path)):
return send_from_directory(static_folder, path)
return send_from_directory(static_folder, 'index.html')
@app.route('/')
def index():
return send_from_directory(app.static_folder, 'index.html')
@app.errorhandler(404)
def handle_404(e):
if request.path.startswith('/api/'):
return jsonify({'error': 'Resource not found', 'message': str(e)}), 404
return send_from_directory(app.static_folder, 'index.html')
@app.errorhandler(400)
def bad_request(e):
return jsonify({'error': 'Bad request', 'message': str(e)}), 400
@app.errorhandler(Exception)
def handle_exception(e):
if isinstance(e, HTTPException):
return jsonify({'error': e.name, 'message': e.description}), e.code
app.logger.error(f"Unhandled exception: {str(e)}", exc_info=True)
return jsonify({'error': 'Internal server error', 'message': 'An unexpected error occurred'}), 500
return app
@@ -43,7 +61,9 @@ def _run_migrations():
"""
migrations = [
'ALTER TABLE projects ADD COLUMN drive_url VARCHAR(500)',
'ALTER TABLE projects ADD COLUMN archived_at DATETIME',
]
with db.engine.connect() as conn:
for stmt in migrations:
try:

View File

@@ -1,5 +1,6 @@
from .extensions import db
from datetime import datetime, date
from datetime import datetime, date, timezone
class Project(db.Model):
__tablename__ = 'projects'
@@ -9,11 +10,14 @@ class Project(db.Model):
color = db.Column(db.String(7), nullable=False, default='#C9A84C')
description = db.Column(db.Text)
drive_url = db.Column(db.String(500))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
archived_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
deliverables = db.relationship(
'Deliverable', backref='project',
cascade='all, delete-orphan', lazy=True
'Deliverable',
backref='project',
cascade='all, delete-orphan',
lazy=True,
)
def to_dict(self, include_deliverables=True):
@@ -23,6 +27,7 @@ class Project(db.Model):
'color': self.color,
'description': self.description,
'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,
}
if include_deliverables:
@@ -36,17 +41,20 @@ class Deliverable(db.Model):
__tablename__ = 'deliverables'
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)
due_date = db.Column(db.Date, nullable=False)
status = db.Column(db.String(20), nullable=False, default='upcoming')
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
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.
Returns 'overdue' if the due date has passed and the deliverable has not been
marked completed. Completed deliverables are never auto-downgraded.
"""
if self.status != 'completed' and self.due_date < date.today():
return 'overdue'

View File

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

View File

@@ -3,21 +3,17 @@ 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 { loadProjects } = useProjectStore()
const { sidebarOpen, toggleSidebar } = useUIStore()
const calApiRef = useRef(null)
const newProjectFn = useRef(null)
useEffect(() => {
setLoading(true)
fetchProjects()
.then(data => { setProjects(data); setLoading(false) })
.catch(() => setLoading(false))
loadProjects()
}, [])
useEffect(() => {

View File

@@ -1,7 +1,11 @@
import axios from 'axios'
const B = '/api'
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 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 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,176 @@
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 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 {
await storeUpdate(deliverable.id, { status: next })
} 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

@@ -7,7 +7,6 @@ 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'
@@ -18,7 +17,7 @@ export default function MainCalendar({ onCalendarReady }) {
const wrapperRef = useRef(null)
const { projects, updateDeliverable: storeUpdate, removeDeliverable } = useProjectStore()
const openFocus = useFocusStore(s => s.openFocus)
const { showHeatmap, toggleHeatmap } = useUIStore()
const { showHeatmap, toggleHeatmap, heatmapJumpDate, clearJumpDate } = useUIStore()
const addToast = useToastStore(s => s.addToast)
const [modal, setModal] = useState({ open: false, deliverable: null, defaultDate: '' })
@@ -27,23 +26,25 @@ export default function MainCalendar({ onCalendarReady }) {
// Expose calendar API to App.jsx for keyboard shortcuts
useEffect(() => {
if (calRef.current && onCalendarReady) {
onCalendarReady(calRef.current.getApi())
}
if (calRef.current && onCalendarReady) onCalendarReady(calRef.current.getApi())
}, [])
// ResizeObserver: call updateSize() on every frame the container changes width
// during the sidebar CSS transition so FullCalendar reflows smoothly
// ResizeObserver: smooth reflow during sidebar CSS transition
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const ro = new ResizeObserver(() => {
calRef.current?.getApi().updateSize()
})
const ro = new ResizeObserver(() => calRef.current?.getApi().updateSize())
ro.observe(el)
return () => ro.disconnect()
}, [])
// Jump to date commanded by HeatmapDayPanel
useEffect(() => {
if (!heatmapJumpDate || !calRef.current) return
calRef.current.getApi().gotoDate(heatmapJumpDate)
clearJumpDate()
}, [heatmapJumpDate, clearJumpDate])
const events = projects.flatMap(p =>
(p.deliverables || []).map(d => ({
id: String(d.id),
@@ -67,32 +68,28 @@ export default function MainCalendar({ onCalendarReady }) {
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 }))
await storeUpdate(deliverableId, { due_date: newDate })
addToast({
message: `Moved to ${newDate}`,
duration: 30,
undoFn: async () => {
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate }))
await storeUpdate(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
@@ -104,14 +101,12 @@ export default function MainCalendar({ onCalendarReady }) {
el.addEventListener('mousemove', (e) => {
setTooltip(prev => prev ? { ...prev, x: e.clientX, y: e.clientY } : null)
})
el.addEventListener('dblclick', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
const { deliverable } = getCtx(projectId, deliverableId)
if (deliverable) setModal({ open: true, deliverable, defaultDate: '' })
})
el.addEventListener('contextmenu', (e) => {
e.preventDefault(); e.stopPropagation()
setTooltip(null)
@@ -120,17 +115,17 @@ export default function MainCalendar({ onCalendarReady }) {
setContextMenu({
x: e.clientX, y: e.clientY,
items: [
{ icon: '\u2714\ufe0e', label: 'Edit Deliverable', action: () => setModal({ open: true, deliverable, defaultDate: '' }) },
{ icon: '\u2756', label: 'Open Focus View', action: () => openFocus(projectId, deliverableId) },
...(project?.drive_url ? [{ icon: '\u2b21', 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: '\u2715', 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 removeDeliverable(deliverableId)
}
},
},
],
})
})
@@ -138,19 +133,22 @@ export default function MainCalendar({ onCalendarReady }) {
return (
<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}
<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 ? '\u2190 Calendar' : '\u2b21 Heatmap'}
}`}
>
{showHeatmap ? '← Calendar' : '⬡ Heatmap'}
</button>
</div>
{/* Main content area */}
{/* Main content */}
<div className="flex-1 overflow-hidden">
{showHeatmap ? (
<WorkloadHeatmap />

View File

@@ -1,50 +1,24 @@
import { useMemo, useState } from 'react'
import { format, startOfWeek, addDays, addWeeks, isToday } from 'date-fns'
import useProjectStore from '../../store/useProjectStore'
import useFocusStore from '../../store/useFocusStore'
import HeatmapDayPanel from './HeatmapDayPanel'
const WEEKS = 20
const DAY_INIT = ['M','T','W','T','F','S','S']
const CELL = 16
const CELL_LG = 40
const GAP_LG = 4
const GAP = 2
const GAP_LG = 4
const STATUS_KEYS = ['upcoming','in_progress','completed','overdue']
const STATUS_LABEL = {
upcoming: 'Upcoming',
in_progress: 'In Progress',
completed: 'Completed',
overdue: 'Overdue',
}
const STATUS_COLOR = {
upcoming: 'text-blue-400',
in_progress: 'text-amber-400',
completed: 'text-green-400',
overdue: 'text-red-400',
}
const STATUS_LABEL = { upcoming: 'Upcoming', in_progress: 'In Progress', completed: 'Completed', overdue: 'Overdue' }
const STATUS_COLOR = { upcoming: 'text-blue-400', in_progress: 'text-amber-400', completed: 'text-green-400', overdue: 'text-red-400' }
const STATUS_CELL_COLORS = {
upcoming: [
'bg-blue-400/20 border-blue-400/30',
'bg-blue-400/55 border-blue-400/70',
'bg-blue-400 border-blue-400 shadow-[0_0_4px_rgba(96,165,250,0.6)]',
],
in_progress: [
'bg-amber-400/20 border-amber-400/30',
'bg-amber-400/55 border-amber-400/70',
'bg-amber-400 border-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.6)]',
],
completed: [
'bg-green-400/20 border-green-400/30',
'bg-green-400/55 border-green-400/70',
'bg-green-400 border-green-400 shadow-[0_0_4px_rgba(74,222,128,0.6)]',
],
overdue: [
'bg-red-400/20 border-red-400/30',
'bg-red-400/55 border-red-400/70',
'bg-red-400 border-red-400 shadow-[0_0_4px_rgba(248,113,113,0.6)]',
],
upcoming: ['bg-blue-400/20 border-blue-400/30', 'bg-blue-400/55 border-blue-400/70', 'bg-blue-400 border-blue-400 shadow-[0_0_4px_rgba(96,165,250,0.6)]'],
in_progress: ['bg-amber-400/20 border-amber-400/30','bg-amber-400/55 border-amber-400/70','bg-amber-400 border-amber-400 shadow-[0_0_4px_rgba(251,191,36,0.6)]'],
completed: ['bg-green-400/20 border-green-400/30','bg-green-400/55 border-green-400/70','bg-green-400 border-green-400 shadow-[0_0_4px_rgba(74,222,128,0.6)]'],
overdue: ['bg-red-400/20 border-red-400/30', 'bg-red-400/55 border-red-400/70', 'bg-red-400 border-red-400 shadow-[0_0_4px_rgba(248,113,113,0.6)]'],
}
const STATUS_HOVER_RING = {
@@ -54,50 +28,57 @@ const STATUS_HOVER_RING = {
overdue: 'hover:ring-1 hover:ring-red-400/90',
}
// Tie-break priority: overdue > in_progress > upcoming > completed
const STATUS_PRIORITY = { overdue: 4, in_progress: 3, upcoming: 2, completed: 1 }
function getCellClass(count, statusKey) {
if (count === 0) return 'bg-surface border-surface-border'
const colors = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
if (count === 1) return colors[0]
if (count === 2) return colors[1]
return colors[2]
const c = STATUS_CELL_COLORS[statusKey] || STATUS_CELL_COLORS.upcoming
if (count === 1) return c[0]
if (count === 2) return c[1]
return c[2]
}
function getDominantStatus(statusCounts) {
let dominant = null
let maxCount = 0
let maxPriority = 0
let dominant = null, maxCount = 0, maxPriority = 0
for (const [sk, count] of Object.entries(statusCounts)) {
if (count === 0) continue
if (
count > maxCount ||
(count === maxCount && (STATUS_PRIORITY[sk] || 0) > maxPriority)
) {
dominant = sk
maxCount = count
maxPriority = STATUS_PRIORITY[sk] || 0
if (count > maxCount || (count === maxCount && (STATUS_PRIORITY[sk] || 0) > maxPriority)) {
dominant = sk; maxCount = count; maxPriority = STATUS_PRIORITY[sk] || 0
}
}
return { dominant, total: Object.values(statusCounts).reduce((a, b) => a + b, 0) }
}
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() {
const projects = useProjectStore(s => s.projects)
const openFocus = useFocusStore(s => s.openFocus)
const [tooltip, setTooltip] = useState(null)
const [selectedDay, setSelectedDay] = useState(null)
const [weekOffset, setWeekOffset] = useState(0)
const { weeks, stats } = useMemo(() => {
const start = startOfWeek(addWeeks(new Date(), -10), { weekStartsOn: 1 })
const start = startOfWeek(addWeeks(new Date(), -10 + weekOffset), { weekStartsOn: 1 })
const map = {}
projects.forEach(p => {
(p.deliverables || []).forEach(d => {
;(p.deliverables || []).forEach(d => {
const key = d.due_date
if (!map[key]) map[key] = { items: [], statusCounts: { upcoming: 0, in_progress: 0, completed: 0, overdue: 0 } }
map[key].items.push({ deliverable: d, project: p })
const s = (d.status || 'upcoming')
const s = d.status || 'upcoming'
if (map[key].statusCounts[s] !== undefined) map[key].statusCounts[s]++
})
})
@@ -120,7 +101,13 @@ export default function WorkloadHeatmap() {
overdue: all.filter(d => d.status === 'overdue').length,
}
return { weeks: grid, stats }
}, [projects])
}, [projects, weekOffset])
const windowStart = weeks[0]?.[0]?.date
const windowEnd = weeks[WEEKS - 1]?.[6]?.date
const windowLabel = windowStart && windowEnd
? `${format(windowStart, 'MMM d')} ${format(windowEnd, 'MMM d, yyyy')}`
: ''
const monthLabels = useMemo(() => {
const labels = []; let last = -1
@@ -142,14 +129,37 @@ export default function WorkloadHeatmap() {
return (
<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>
<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>
</div>
<div className="flex items-center gap-3 text-[10px] text-text-muted">
<span className="uppercase tracking-[0.18em] text-text-muted/60">FABDASH</span>
<div className="flex items-center gap-2">
<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>
@@ -161,7 +171,11 @@ export default function WorkloadHeatmap() {
<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>
</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 flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
{DAY_INIT.map((d, i) => (
@@ -183,16 +197,12 @@ export default function WorkloadHeatmap() {
<div
key={key + statusKey}
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)}
${isToday(date) ? 'ring-1 ring-white/60' : ''}
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
`}
onClick={() => {
if (!items?.length) return
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0]
if (match) openFocus(match.project.id, match.deliverable.id)
}}
onClick={() => setSelectedDay(date)}
onMouseEnter={(e) => {
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
if (!filtered.length) return
@@ -209,10 +219,11 @@ export default function WorkloadHeatmap() {
</div>
</div>
</div>
</div>
))}
</div>
{/* Combined heatmap */}
{/* Combined All Tasks heatmap */}
<div className="px-8 pb-8">
<div className="bg-surface-elevated/40 border border-surface-border rounded-xl p-5">
<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>
</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 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 }}>
{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>
{/* Grid */}
<div className="flex flex-col flex-shrink-0">
<div className="relative h-5 mb-1">
{monthLabelsBig.map(({ wi, label }) => (
@@ -255,26 +262,15 @@ export default function WorkloadHeatmap() {
<div
key={key + 'combined'}
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'}
${isToday(date) ? 'ring-1 ring-white/60' : ''}
${dominant ? STATUS_HOVER_RING[dominant] : ''}
`}
onClick={() => {
if (!items?.length) return
openFocus(items[0].project.id, items[0].deliverable.id)
}}
onClick={() => setSelectedDay(date)}
onMouseEnter={(e) => {
if (!items?.length) return
setTooltip({
x: e.clientX,
y: e.clientY,
date,
statusKey: dominant,
items,
combined: true,
statusCounts,
})
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey: dominant, items, combined: true, statusCounts })
}}
onMouseLeave={() => setTooltip(null)}
/>
@@ -288,14 +284,14 @@ export default function WorkloadHeatmap() {
</div>
</div>
{/* Tooltip */}
{/* Hover 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-[220px] max-w-[320px]"
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'}`}>
{isToday(tooltip.date) ? 'Today \u2014 ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
{isToday(tooltip.date) ? 'Today ' : ''}{format(tooltip.date, 'EEE, MMM d, yyyy')}
</p>
{tooltip.combined && tooltip.statusCounts ? (
<div className="space-y-1 mb-2">
@@ -308,7 +304,7 @@ export default function WorkloadHeatmap() {
</div>
) : (
<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>
)}
<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>
)}
</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>
)}
{/* Day detail panel */}
{selectedDay && (
<HeatmapDayPanel
date={selectedDay}
onClose={() => setSelectedDay(null)}
/>
)}
</div>
)
}

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import Modal from '../UI/Modal'
import Button from '../UI/Button'
import { createDeliverable, updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -25,7 +24,8 @@ export default function DeliverableModal({ isOpen, onClose, deliverable, project
const handleDelete = async () => {
if (!window.confirm('Delete this deliverable?')) return
await deleteDeliverable(deliverable.id); removeDeliverable(deliverable.id); onClose()
await removeDeliverable(deliverable.id)
onClose()
}
const handleSubmit = async () => {
@@ -33,9 +33,9 @@ export default function DeliverableModal({ isOpen, onClose, deliverable, project
setSaving(true)
try {
if (isEditing) {
storeUpdate(await updateDeliverable(deliverable.id, { title, due_date: dueDate, status }))
await storeUpdate(deliverable.id, { title, due_date: dueDate, status })
} else {
addDeliverable(await createDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) }))
await addDeliverable({ title, due_date: dueDate, status, project_id: Number(pid) })
}
onClose()
} finally { setSaving(false) }

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
import ContextMenu from '../UI/ContextMenu'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -14,24 +13,31 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
e.preventDefault()
e.stopPropagation()
setCtxMenu({
x: e.clientX, y: e.clientY,
x: e.clientX,
y: e.clientY,
items: [
{ icon: '✎', label: 'Edit Deliverable', highlight: true, action: () => onEdit(deliverable) },
{
icon: '✎',
label: 'Edit Deliverable',
highlight: true,
action: () => onEdit(deliverable),
},
{ separator: true },
...STATUS_OPTIONS.map(s => ({
icon: s.value === deliverable.status ? '●' : '○',
label: `Mark ${s.label}`,
action: async () => {
storeUpdate(await updateDeliverable(deliverable.id, { status: s.value }))
await storeUpdate(deliverable.id, { status: s.value })
},
})),
{ separator: true },
{
icon: '✕', label: 'Delete Deliverable', danger: true,
icon: '✕',
label: 'Delete Deliverable',
danger: true,
action: async () => {
if (window.confirm(`Delete "${deliverable.title}"?`)) {
await deleteDeliverable(deliverable.id)
removeDeliverable(deliverable.id)
await removeDeliverable(deliverable.id)
}
},
},
@@ -46,43 +52,30 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
onDoubleClick={(e) => { e.stopPropagation(); onEdit(deliverable) }}
onContextMenu={handleContextMenu}
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
transition-all duration-200 select-none mt-4
${isActive
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 ${
isActive
? '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'
}`}
>
{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
</div>
)}
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: projectColor }} />
<span className={`text-[10px] font-semibold uppercase tracking-widest ${isActive ? 'text-gold' : 'text-text-muted/60'}`}>
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>
<div className="text-[10px] text-text-muted/50 font-mono">Deliverable {index + 1}</div>
<div className="text-sm font-semibold text-text-primary leading-snug line-clamp-3">{deliverable.title}</div>
<div className="text-xs text-text-muted/70 mt-auto pt-1">{formatDate(deliverable.due_date)}</div>
<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>
{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

@@ -3,26 +3,20 @@ import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers'
import useFocusStore from '../../store/useFocusStore'
import useProjectStore from '../../store/useProjectStore'
import { deleteDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu'
function DriveIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" className="w-3.5 h-3.5">
<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="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 width="11" height="11" viewBox="0 0 24 24" fill="currentColor">
<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"/>
</svg>
)
}
export default function ProjectCard({ project, onEdit, onDelete }) {
export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) {
const openFocus = useFocusStore(s => s.openFocus)
const { removeDeliverable } = useProjectStore()
const { removeDeliverable, toggleArchive } = useProjectStore()
const [delModal, setDelModal] = useState({ open: false, deliverable: null })
const [ctxMenu, setCtxMenu] = useState(null)
@@ -41,8 +35,7 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
icon: '✕', label: 'Delete Deliverable', danger: true,
action: async () => {
if (window.confirm(`Delete "${d.title}"?`)) {
await deleteDeliverable(d.id)
removeDeliverable(d.id)
await removeDeliverable(d.id)
}
},
},
@@ -52,11 +45,22 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
const handleHeaderCtx = (e) => {
e.preventDefault()
const isArchived = !!project.archived_at
setCtxMenu({
x: e.clientX, y: e.clientY,
items: [
{ 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 () => {
await toggleArchive(project.id, isArchived)
onArchiveToggle?.()
},
},
{ separator: true },
{ icon: '✕', label: 'Delete Project', danger: true, action: () => onDelete(project) },
],
@@ -64,61 +68,68 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
}
return (
<div className="bg-surface-elevated border border-surface-border rounded-lg overflow-hidden transition-all hover:border-gold/20">
<div className="h-1 w-full" style={{ backgroundColor: project.color }} />
<div className="p-3">
<div className={`rounded-xl border bg-surface-raised overflow-hidden ${
project.archived_at ? 'opacity-60 border-surface-border' : 'border-surface-border'
}`}>
{/* Header — double-click to edit, right-click for menu */}
{/* Header */}
<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)}
onContextMenu={handleHeaderCtx}
title="Double-click to edit project"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<span className="text-sm font-semibold text-text-primary truncate">{project.name}</span>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1">
<div className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: project.color }} />
<span className="font-semibold text-sm text-text-primary truncate flex-1">{project.name}</span>
{project.archived_at && (
<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 && (
<a href={project.drive_url} target="_blank" rel="noopener noreferrer"
title="Open Google Drive folder" 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 /><span>Drive</span>
<a
href={project.drive_url}
target="_blank"
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>
)}
<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>
</div>
</div>
{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 */}
<div className="space-y-1">
<div className="px-2 pb-2 space-y-1">
{(project.deliverables || []).map(d => (
<button
key={d.id}
onClick={() => openFocus(project.id, d.id)}
onDoubleClick={(e) => { e.stopPropagation(); openDelEdit(d) }}
onContextMenu={(e) => handleRowCtx(e, d)}
onDoubleClick={e => { e.stopPropagation(); openDelEdit(d) }}
onContextMenu={e => handleRowCtx(e, d)}
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"
>
<span className="text-text-muted group-hover:text-text-primary truncate flex-1 pr-2">{d.title}</span>
<div className="flex items-center gap-1.5 flex-shrink-0">
<span className="text-text-muted/60 font-mono text-[9px]">{formatDate(d.due_date)}</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 ml-2">
<span className="text-text-muted/60 text-[10px]">{formatDate(d.due_date)}</span>
<Badge status={d.status} />
</div>
</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>
{/* Local deliverable edit modal */}

View File

@@ -5,28 +5,55 @@ 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'
const VIEW_OPTIONS = [
{ key: 'active', label: 'Active' },
{ key: 'archived', label: 'Archived' },
{ key: 'all', label: 'All' },
]
export default function ProjectList({ onRegisterNewProject }) {
const { projects, removeProject } = useProjectStore()
const { projects, deleteProject, loadProjects } = useProjectStore()
const { sidebarTab, setSidebarTab } = useUIStore()
const [showModal, setShowModal] = useState(false)
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 () => {
await loadProjects()
}
const handleEdit = (p) => { setEditing(p); setShowModal(true) }
const handleDelete = async (p) => {
if (window.confirm(`Delete "${p.name}" and all its deliverables?`)) {
await deleteProject(p.id); removeProject(p.id)
await deleteProject(p.id)
}
}
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 (
<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">
<img
src="/logo.png"
@@ -48,7 +75,11 @@ export default function ProjectList({ onRegisterNewProject }) {
<div className="flex border-b border-surface-border flex-shrink-0">
{[['projects','Projects'],['agenda','Upcoming']].map(([key, label]) => (
<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}
</button>
))}
@@ -58,7 +89,41 @@ export default function ProjectList({ onRegisterNewProject }) {
<div className="flex-1 overflow-y-auto">
{sidebarTab === 'projects' ? (
<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="opacity-20 mb-3">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none">
@@ -70,14 +135,16 @@ export default function ProjectList({ onRegisterNewProject }) {
<line x1="17" y1="39" x2="30" y2="39" stroke="#C9A84C" strokeWidth="1.5" strokeDasharray="4 2.5"/>
</svg>
</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">
Press <kbd className="bg-surface-border px-1.5 py-0.5 rounded text-[10px] font-mono">N</kbd> or click + Project
</p>
</div>
) : (
projects.map(p => (
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} />
visibleProjects.map(p => (
<ProjectCard key={p.id} project={p} onEdit={handleEdit} onDelete={handleDelete} onArchiveToggle={refreshProjects} />
))
)}
</div>

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'
import Modal from '../UI/Modal'
import Button from '../UI/Button'
import { createProject, updateProject } from '../../api/projects'
import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -9,7 +8,7 @@ const PALETTE = ['#4A90D9','#2ECC9A','#9B59B6','#E74C3C','#E67E22','#27AE60','#E
const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' })
export default function ProjectModal({ isOpen, onClose, project }) {
const { addProject, updateProject: storeUpdate } = useProjectStore()
const { createProject, updateProject } = useProjectStore()
const [name, setName] = useState('')
const [desc, setDesc] = useState('')
const [color, setColor] = useState('#4A90D9')
@@ -39,12 +38,10 @@ export default function ProjectModal({ isOpen, onClose, project }) {
setSaving(true)
try {
if (isEditing) {
const updated = await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl })
storeUpdate({ ...updated, deliverables: project.deliverables })
await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl })
} else {
const valid = rows.filter(r => r.title.trim() && r.due_date)
const created = await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid })
addProject(created)
await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid })
}
onClose()
} finally { setSaving(false) }

View File

@@ -1,11 +1,16 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export default function ContextMenu({ x, y, items, onClose }) {
const ref = useRef(null)
useEffect(() => {
const onMouseDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
const onKey = (e) => { if (e.key === 'Escape') onClose() }
const onMouseDown = (e) => {
if (ref.current && !ref.current.contains(e.target)) onClose()
}
const onKey = (e) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', onMouseDown)
document.addEventListener('keydown', onKey)
return () => {
@@ -20,36 +25,36 @@ export default function ContextMenu({ x, y, items, onClose }) {
const adjX = Math.min(x, window.innerWidth - W - 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
ref={ref}
style={{ 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"
style={{ position: 'fixed', left: adjX, top: adjY }}
className="z-[9999] bg-surface-elevated border border-surface-border rounded-xl shadow-2xl py-1.5 min-w-[192px]"
>
{items.map((item, i) =>
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
key={i}
disabled={item.disabled}
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
${item.danger
? 'text-red-400 hover:bg-red-500/10'
: item.highlight
? 'text-gold hover:bg-gold/10'
: 'text-text-primary hover:bg-surface-border/40'
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 ${
item.danger ? 'text-red-400 hover:bg-red-500/10' :
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>{item.label}</span>
<span className="text-[11px] opacity-70 w-3.5 flex-shrink-0">{item.icon}</span>
<span className="flex-1">{item.label}</span>
{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>
)
)}
</div>
</div>,
document.body
)
}

View File

@@ -1,41 +1,117 @@
import { create } from 'zustand'
import * as projectApi from '../api/projects'
import * as deliverableApi from '../api/deliverables'
const useProjectStore = create((set, get) => ({
projects: [],
loading: false,
error: null,
setProjects: (projects) => set({ projects }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
getProjectById: (id) => get().projects.find(p => p.id === id),
addProject: (p) => set((s) => ({ projects: [p, ...s.projects] })),
// Async Actions
loadProjects: async () => {
set({ loading: true, error: null })
try {
const data = await projectApi.fetchProjects()
set({ projects: data, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
updateProject: (updated) => set((s) => ({
createProject: async (data) => {
set({ loading: true })
try {
const newProj = await projectApi.createProject(data)
set(s => ({ projects: [newProj, ...s.projects], loading: false }))
return newProj
} catch (err) {
set({ error: err.message, loading: false })
throw err
}
},
updateProject: async (id, data) => {
try {
const updated = await projectApi.updateProject(id, data)
set(s => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
})),
}))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeProject: (id) => set((s) => ({ projects: s.projects.filter(p => p.id !== id) })),
deleteProject: async (id) => {
try {
await projectApi.deleteProject(id)
set(s => ({ projects: s.projects.filter(p => p.id !== id) }))
} catch (err) {
set({ error: err.message })
throw err
}
},
addDeliverable: (d) => set((s) => ({
toggleArchive: async (id, isArchived) => {
try {
const updated = isArchived
? await projectApi.unarchiveProject(id)
: await projectApi.archiveProject(id)
set(s => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
}))
} catch (err) {
set({ error: err.message })
throw err
}
},
addDeliverable: async (data) => {
try {
const d = await deliverableApi.createDeliverable(data)
set(s => ({
projects: s.projects.map(p => p.id === d.project_id
? { ...p, deliverables: [...(p.deliverables||[]), d].sort((a,b) => new Date(a.due_date)-new Date(b.due_date)) }
: p
),
})),
}))
return d
} catch (err) {
set({ error: err.message })
throw err
}
},
updateDeliverable: (updated) => set((s) => ({
updateDeliverable: async (id, data) => {
try {
const updated = await deliverableApi.updateDeliverable(id, data)
set(s => ({
projects: s.projects.map(p => p.id === updated.project_id
? { ...p, deliverables: p.deliverables.map(d => d.id === updated.id ? updated : d) }
? { ...p, deliverables: (p.deliverables||[]).map(d => d.id === updated.id ? updated : d) }
: p
),
})),
}))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeDeliverable: (id) => set((s) => ({
removeDeliverable: async (id) => {
try {
await deliverableApi.deleteDeliverable(id)
set(s => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
})),
getProjectById: (id) => get().projects.find(p => p.id === id),
}))
} catch (err) {
set({ error: err.message })
throw err
}
},
}))
export default useProjectStore

View File

@@ -1,13 +1,17 @@
import { create } from 'zustand'
const useUIStore = create((set) => ({
export default 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
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 }),
}))