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 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 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 +21,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')
@@ -24,13 +29,26 @@ def create_app(config_name=None):
db.create_all() db.create_all()
_run_migrations() _run_migrations()
@app.route('/', defaults={'path': ''}) @app.route('/')
@app.route('/<path:path>') def index():
def serve_react(path): return send_from_directory(app.static_folder, 'index.html')
static_folder = app.static_folder
if path and os.path.exists(os.path.join(static_folder, path)): @app.errorhandler(404)
return send_from_directory(static_folder, path) def handle_404(e):
return send_from_directory(static_folder, 'index.html') 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 return app
@@ -43,7 +61,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,5 +1,6 @@
from .extensions import db from .extensions import db
from datetime import datetime, date from datetime import datetime, date, timezone
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))
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( 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,17 +41,20 @@ 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')
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): 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, timezone
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.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']) @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

@@ -3,21 +3,17 @@ import ProjectList from './components/Projects/ProjectList'
import MainCalendar from './components/Calendar/MainCalendar' import MainCalendar from './components/Calendar/MainCalendar'
import FocusDrawer from './components/FocusView/FocusDrawer' import FocusDrawer from './components/FocusView/FocusDrawer'
import ToastContainer from './components/UI/Toast' import ToastContainer from './components/UI/Toast'
import { fetchProjects } from './api/projects'
import useProjectStore from './store/useProjectStore' import useProjectStore from './store/useProjectStore'
import useUIStore from './store/useUIStore' import useUIStore from './store/useUIStore'
export default function App() { export default function App() {
const { setProjects, setLoading } = useProjectStore() const { loadProjects } = useProjectStore()
const { sidebarOpen, toggleSidebar } = useUIStore() const { sidebarOpen, toggleSidebar } = useUIStore()
const calApiRef = useRef(null) const calApiRef = useRef(null)
const newProjectFn = useRef(null) const newProjectFn = useRef(null)
useEffect(() => { useEffect(() => {
setLoading(true) loadProjects()
fetchProjects()
.then(data => { setProjects(data); setLoading(false) })
.catch(() => setLoading(false))
}, []) }, [])
useEffect(() => { useEffect(() => {

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,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 useFocusStore from '../../store/useFocusStore'
import useUIStore from '../../store/useUIStore' import useUIStore from '../../store/useUIStore'
import useToastStore from '../../store/useToastStore' import useToastStore from '../../store/useToastStore'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import DeliverableModal from '../Deliverables/DeliverableModal' import DeliverableModal from '../Deliverables/DeliverableModal'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
import EventTooltip from './EventTooltip' import EventTooltip from './EventTooltip'
@@ -17,9 +16,9 @@ export default function MainCalendar({ onCalendarReady }) {
const calRef = useRef(null) const calRef = useRef(null)
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: '' })
const [contextMenu, setContextMenu] = useState(null) const [contextMenu, setContextMenu] = useState(null)
@@ -27,23 +26,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,32 +68,28 @@ 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)
const oldDate = oldEvent.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({ addToast({
message: `Moved to ${newDate}`, message: `Moved to ${newDate}`,
duration: 30, duration: 30,
undoFn: async () => { undoFn: async () => {
storeUpdate(await updateDeliverable(deliverableId, { due_date: oldDate })) await storeUpdate(deliverableId, { due_date: oldDate })
}, },
}) })
}, [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 +101,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,16 +115,16 @@ 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 removeDeliverable(deliverableId)
} }
} },
}, },
], ],
}) })
@@ -138,19 +133,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]++
}) })
}) })
@@ -111,7 +92,7 @@ export default function WorkloadHeatmap() {
}) })
) )
const all = projects.flatMap(p => p.deliverables || []) const all = projects.flatMap(p => p.deliverables || [])
const stats = { const stats = {
total: all.length, total: all.length,
upcoming: all.filter(d => d.status === 'upcoming').length, upcoming: all.filter(d => d.status === 'upcoming').length,
@@ -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,49 +171,50 @@ 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 gap-2 overflow-x-auto pb-1 justify-center"> <div className="flex justify-end">
<div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}> <DensityLegend statusKey={statusKey} />
{DAY_INIT.map((d, i) => ( </div>
<div key={i} style={{ height: CELL }} className="flex items-center text-[9px] text-text-muted/50 font-mono w-3">{d}</div> <div className="flex items-center justify-center flex-1">
))} <div className="flex gap-2 overflow-x-auto pb-1 justify-center">
</div> <div className="flex flex-col flex-shrink-0 pt-6" style={{ gap: GAP }}>
<div className="flex flex-col flex-shrink-0"> {DAY_INIT.map((d, i) => (
<div className="relative h-4 mb-1"> <div key={i} style={{ height: CELL }} className="flex items-center text-[9px] text-text-muted/50 font-mono w-3">{d}</div>
{monthLabels.map(({ wi, label }) => (
<span key={label+wi} className="absolute text-[9px] text-text-muted/60 font-medium" style={{ left: wi * (CELL + GAP) }}>{label}</span>
))} ))}
</div> </div>
<div className="flex" style={{ gap: GAP }}> <div className="flex flex-col flex-shrink-0">
{weeks.map((week, wi) => ( <div className="relative h-4 mb-1">
<div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}> {monthLabels.map(({ wi, label }) => (
{week.map(({ date, key, items, statusCounts }) => { <span key={label+wi} className="absolute text-[9px] text-text-muted/60 font-medium" style={{ left: wi * (CELL + GAP) }}>{label}</span>
const count = (statusCounts || {})[statusKey] || 0 ))}
return ( </div>
<div <div className="flex" style={{ gap: GAP }}>
key={key + statusKey} {weeks.map((week, wi) => (
style={{ width: CELL, height: CELL }} <div key={wi} className="flex flex-col flex-shrink-0" style={{ gap: GAP }}>
className={`rounded-sm border cursor-pointer transition-transform duration-100 hover:scale-125 hover:z-10 relative {week.map(({ date, key, items, statusCounts }) => {
${getCellClass(count, statusKey)} const count = (statusCounts || {})[statusKey] || 0
${isToday(date) ? 'ring-1 ring-white/60' : ''} return (
${count > 0 ? STATUS_HOVER_RING[statusKey] : ''} <div
`} key={key + statusKey}
onClick={() => { style={{ width: CELL, height: CELL }}
if (!items?.length) return className={`rounded-sm border cursor-pointer transition-transform duration-100 hover:scale-125 hover:z-10
const match = items.find(({ deliverable }) => deliverable.status === statusKey) || items[0] ${getCellClass(count, statusKey)}
if (match) openFocus(match.project.id, match.deliverable.id) ${isToday(date) ? 'ring-1 ring-white/60' : ''}
}} ${count > 0 ? STATUS_HOVER_RING[statusKey] : ''}
onMouseEnter={(e) => { `}
const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey) onClick={() => setSelectedDay(date)}
if (!filtered.length) return onMouseEnter={(e) => {
setTooltip({ x: e.clientX, y: e.clientY, date, statusKey, items: filtered }) const filtered = (items || []).filter(({ deliverable }) => deliverable.status === statusKey)
}} if (!filtered.length) return
onMouseLeave={() => setTooltip(null)} setTooltip({ x: e.clientX, y: e.clientY, date, statusKey, items: filtered })
/> }}
) onMouseLeave={() => setTooltip(null)}
})} />
</div> )
))} })}
</div>
))}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -212,7 +223,7 @@ export default function WorkloadHeatmap() {
))} ))}
</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

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

View File

@@ -2,7 +2,6 @@ import { useState } from 'react'
import Badge from '../UI/Badge' import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers' import { formatDate } from '../../utils/dateHelpers'
import ContextMenu from '../UI/ContextMenu' import ContextMenu from '../UI/ContextMenu'
import { updateDeliverable, deleteDeliverable } from '../../api/deliverables'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers' import { STATUS_OPTIONS } from '../../utils/statusHelpers'
@@ -14,24 +13,31 @@ 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 ? '●' : '○',
label: `Mark ${s.label}`, label: `Mark ${s.label}`,
action: async () => { action: async () => {
storeUpdate(await updateDeliverable(deliverable.id, { status: s.value })) await storeUpdate(deliverable.id, { status: s.value })
}, },
})), })),
{ 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 removeDeliverable(deliverable.id)
removeDeliverable(deliverable.id)
} }
}, },
}, },
@@ -45,44 +51,31 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC
onClick={() => onSelect(deliverable.id)} onClick={() => onSelect(deliverable.id)}
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

@@ -3,28 +3,22 @@ import Badge from '../UI/Badge'
import { formatDate } from '../../utils/dateHelpers' 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 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, toggleArchive } = 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)
const openDelEdit = (d) => setDelModal({ open: true, deliverable: d }) const openDelEdit = (d) => setDelModal({ open: true, deliverable: d })
@@ -41,8 +35,7 @@ export default function ProjectCard({ project, onEdit, onDelete }) {
icon: '✕', label: 'Delete Deliverable', danger: true, icon: '✕', label: 'Delete Deliverable', danger: true,
action: async () => { action: async () => {
if (window.confirm(`Delete "${d.title}"?`)) { if (window.confirm(`Delete "${d.title}"?`)) {
await deleteDeliverable(d.id) await removeDeliverable(d.id)
removeDeliverable(d.id)
} }
}, },
}, },
@@ -52,11 +45,22 @@ 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 () => {
await toggleArchive(project.id, isArchived)
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 +68,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>
<div className="flex items-center gap-0.5 flex-shrink-0 ml-1">
{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>
)}
<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 && ( {project.archived_at && (
<p className="text-xs text-text-muted mb-2 line-clamp-1">{project.description}</p> <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>
)} )}
{/* Deliverable rows */} {project.drive_url && (
<div className="space-y-1"> <a
{(project.deliverables || []).map(d => ( href={project.drive_url}
<button target="_blank"
key={d.id} rel="noreferrer"
onClick={() => openFocus(project.id, d.id)} onClick={e => e.stopPropagation()}
onDoubleClick={(e) => { e.stopPropagation(); openDelEdit(d) }} 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"
onContextMenu={(e) => handleRowCtx(e, d)} >
title="Click: Focus View · Double-click: Edit · Right-click: Menu" <DriveIcon /> Drive
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" </a>
> )}
<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>
<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>
<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>
{project.description && (
<p className="text-text-muted text-xs px-3 pb-1.5 -mt-1 truncate">{project.description}</p>
)}
{/* Deliverable rows */}
<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)}
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="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-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,55 @@ 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'
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, deleteProject, loadProjects } = 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 () => {
await loadProjects()
}
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)
} }
} }
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 +75,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 +89,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 +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"/> <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,7 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Modal from '../UI/Modal' import Modal from '../UI/Modal'
import Button from '../UI/Button' import Button from '../UI/Button'
import { createProject, updateProject } from '../../api/projects'
import useProjectStore from '../../store/useProjectStore' import useProjectStore from '../../store/useProjectStore'
import { STATUS_OPTIONS } from '../../utils/statusHelpers' 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' }) const emptyRow = () => ({ title: '', due_date: '', status: 'upcoming' })
export default function ProjectModal({ isOpen, onClose, project }) { export default function ProjectModal({ isOpen, onClose, project }) {
const { addProject, updateProject: storeUpdate } = useProjectStore() const { createProject, updateProject } = useProjectStore()
const [name, setName] = useState('') const [name, setName] = useState('')
const [desc, setDesc] = useState('') const [desc, setDesc] = useState('')
const [color, setColor] = useState('#4A90D9') const [color, setColor] = useState('#4A90D9')
@@ -39,12 +38,10 @@ export default function ProjectModal({ isOpen, onClose, project }) {
setSaving(true) setSaving(true)
try { try {
if (isEditing) { if (isEditing) {
const updated = await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl }) await updateProject(project.id, { name, description: desc, color, drive_url: driveUrl })
storeUpdate({ ...updated, deliverables: project.deliverables })
} else { } else {
const valid = rows.filter(r => r.title.trim() && r.due_date) const valid = rows.filter(r => r.title.trim() && r.due_date)
const created = await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid }) await createProject({ name, description: desc, color, drive_url: driveUrl, deliverables: valid })
addProject(created)
} }
onClose() onClose()
} finally { setSaving(false) } } finally { setSaving(false) }

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 () => {
@@ -15,41 +20,41 @@ export default function ContextMenu({ x, y, items, onClose }) {
}, [onClose]) }, [onClose])
// Keep menu inside viewport // Keep menu inside viewport
const W = 192 const W = 192
const H = items.length * 34 const H = items.length * 34
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,41 +1,117 @@
import { create } from 'zustand' import { create } from 'zustand'
import * as projectApi from '../api/projects'
import * as deliverableApi from '../api/deliverables'
const useProjectStore = create((set, get) => ({ const useProjectStore = create((set, get) => ({
projects: [], projects: [],
loading: false, loading: false,
error: null, error: null,
setProjects: (projects) => set({ projects }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
addProject: (p) => set((s) => ({ projects: [p, ...s.projects] })),
updateProject: (updated) => set((s) => ({
projects: s.projects.map(p => p.id === updated.id ? { ...updated, deliverables: p.deliverables } : p),
})),
removeProject: (id) => set((s) => ({ projects: s.projects.filter(p => p.id !== id) })),
addDeliverable: (d) => 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
),
})),
updateDeliverable: (updated) => 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
),
})),
removeDeliverable: (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), getProjectById: (id) => get().projects.find(p => p.id === id),
// 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 })
}
},
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
}
},
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
}
},
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: 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
),
}))
return updated
} catch (err) {
set({ error: err.message })
throw err
}
},
removeDeliverable: async (id) => {
try {
await deliverableApi.deleteDeliverable(id)
set(s => ({
projects: s.projects.map(p => ({ ...p, deliverables: (p.deliverables||[]).filter(d => d.id !== id) })),
}))
} catch (err) {
set({ error: err.message })
throw err
}
},
})) }))
export default useProjectStore export default useProjectStore

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' toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
showHeatmap: false,
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })), sidebarTab: 'projects',
setSidebarTab: (tab) => set({ sidebarTab: tab }), setSidebarTab: (tab) => set({ sidebarTab: tab }),
toggleHeatmap: () => set(s => ({ showHeatmap: !s.showHeatmap })),
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 }),
})) }))
export default useUIStore