From a1f8c90801f0c7b3171ef7dd238ceda43a5e9735 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 12 Mar 2026 10:23:22 -0500 Subject: [PATCH] updates, back and front --- ROADMAP.md | 3 +- backend/app/__init__.py | 18 ++- backend/app/models.py | 6 +- backend/app/routes/projects.py | 4 +- frontend/src/App.jsx | 8 +- .../src/components/Calendar/MainCalendar.jsx | 12 +- .../Deliverables/DeliverableModal.jsx | 8 +- .../components/FocusView/DeliverableCard.jsx | 6 +- .../src/components/Projects/ProjectCard.jsx | 12 +- .../src/components/Projects/ProjectModal.jsx | 9 +- frontend/src/store/useProjectStore.js | 136 ++++++++++++++---- 11 files changed, 147 insertions(+), 75 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d0f0ac7..a3c7a39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -43,6 +43,7 @@ A sleek, modern fabrication dashboard focused on simplicity and clarity. ### 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. @@ -62,4 +63,4 @@ A sleek, modern fabrication dashboard focused on simplicity and clarity. - [ ] **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. -- [ ] **Global Exception Handling**: Standardized API error responses. +- [x] **Global Exception Handling**: Standardized API error responses. diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 12253d2..1ebb729 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -1,6 +1,7 @@ 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 @@ -35,6 +36,21 @@ def create_app(config_name=None): 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.errorhandler(404) + def not_found(e): + return jsonify({'error': 'Resource not found', 'message': str(e)}), 404 + + @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 diff --git a/backend/app/models.py b/backend/app/models.py index b90dba1..150c28a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,5 +1,5 @@ from .extensions import db -from datetime import datetime, date +from datetime import datetime, date, timezone class Project(db.Model): @@ -11,7 +11,7 @@ class Project(db.Model): description = db.Column(db.Text) drive_url = db.Column(db.String(500)) archived_at = db.Column(db.DateTime, nullable=True) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) deliverables = db.relationship( 'Deliverable', @@ -49,7 +49,7 @@ class Deliverable(db.Model): 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): """ diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py index aae73e2..308bd23 100644 --- a/backend/app/routes/projects.py +++ b/backend/app/routes/projects.py @@ -1,7 +1,7 @@ from flask import Blueprint, jsonify, request from ..models import Project, Deliverable from ..extensions import db -from datetime import date, datetime +from datetime import date, datetime, timezone projects_bp = Blueprint('projects', __name__) @@ -57,7 +57,7 @@ def update_project(id): @projects_bp.route('/projects//archive', methods=['PATCH']) def archive_project(id): project = Project.query.get_or_404(id) - project.archived_at = datetime.utcnow() + project.archived_at = datetime.now(timezone.utc) db.session.commit() return jsonify(project.to_dict()) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 5124b2e..9242047 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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(() => { diff --git a/frontend/src/components/Calendar/MainCalendar.jsx b/frontend/src/components/Calendar/MainCalendar.jsx index a54b9ea..ec95311 100644 --- a/frontend/src/components/Calendar/MainCalendar.jsx +++ b/frontend/src/components/Calendar/MainCalendar.jsx @@ -4,10 +4,6 @@ import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' import useProjectStore from '../../store/useProjectStore' -import useFocusStore from '../../store/useFocusStore' -import useUIStore from '../../store/useUIStore' -import useToastStore from '../../store/useToastStore' -import { updateDeliverable, deleteDeliverable } from '../../api/deliverables' import DeliverableModal from '../Deliverables/DeliverableModal' import ContextMenu from '../UI/ContextMenu' import EventTooltip from './EventTooltip' @@ -70,15 +66,14 @@ export default function MainCalendar({ onCalendarReady }) { }, [openFocus]) 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]) @@ -123,8 +118,7 @@ export default function MainCalendar({ onCalendarReady }) { { icon: '✕', label: 'Delete Deliverable', danger: true, action: async () => { if (window.confirm(`Delete "${deliverable.title}"?`)) { - await deleteDeliverable(deliverableId) - removeDeliverable(deliverableId) + await removeDeliverable(deliverableId) } }, }, diff --git a/frontend/src/components/Deliverables/DeliverableModal.jsx b/frontend/src/components/Deliverables/DeliverableModal.jsx index 375a9c4..0d42d12 100644 --- a/frontend/src/components/Deliverables/DeliverableModal.jsx +++ b/frontend/src/components/Deliverables/DeliverableModal.jsx @@ -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) } diff --git a/frontend/src/components/FocusView/DeliverableCard.jsx b/frontend/src/components/FocusView/DeliverableCard.jsx index 8f51905..912c7b7 100644 --- a/frontend/src/components/FocusView/DeliverableCard.jsx +++ b/frontend/src/components/FocusView/DeliverableCard.jsx @@ -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' @@ -28,7 +27,7 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC 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 }, @@ -38,8 +37,7 @@ export default function DeliverableCard({ deliverable, isActive, index, projectC danger: true, action: async () => { if (window.confirm(`Delete "${deliverable.title}"?`)) { - await deleteDeliverable(deliverable.id) - removeDeliverable(deliverable.id) + await removeDeliverable(deliverable.id) } }, }, diff --git a/frontend/src/components/Projects/ProjectCard.jsx b/frontend/src/components/Projects/ProjectCard.jsx index d55ac61..5867f16 100644 --- a/frontend/src/components/Projects/ProjectCard.jsx +++ b/frontend/src/components/Projects/ProjectCard.jsx @@ -3,8 +3,6 @@ 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 { archiveProject, unarchiveProject } from '../../api/projects' import DeliverableModal from '../Deliverables/DeliverableModal' import ContextMenu from '../UI/ContextMenu' @@ -18,7 +16,7 @@ function DriveIcon() { export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle }) { const openFocus = useFocusStore(s => s.openFocus) - const { removeDeliverable, updateProject } = useProjectStore() + const { removeDeliverable, toggleArchive } = useProjectStore() const [delModal, setDelModal] = useState({ open: false, deliverable: null }) const [ctxMenu, setCtxMenu] = useState(null) @@ -37,8 +35,7 @@ export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle 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) } }, }, @@ -60,10 +57,7 @@ export default function ProjectCard({ project, onEdit, onDelete, onArchiveToggle icon: isArchived ? '↺' : '⏸', label: isArchived ? 'Unarchive Project' : 'Archive Project', action: async () => { - const updated = isArchived - ? await unarchiveProject(project.id) - : await archiveProject(project.id) - updateProject(updated) + await toggleArchive(project.id, isArchived) onArchiveToggle?.() }, }, diff --git a/frontend/src/components/Projects/ProjectModal.jsx b/frontend/src/components/Projects/ProjectModal.jsx index a2936ed..5c43a9a 100644 --- a/frontend/src/components/Projects/ProjectModal.jsx +++ b/frontend/src/components/Projects/ProjectModal.jsx @@ -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) } diff --git a/frontend/src/store/useProjectStore.js b/frontend/src/store/useProjectStore.js index e9707d3..73df529 100644 --- a/frontend/src/store/useProjectStore.js +++ b/frontend/src/store/useProjectStore.js @@ -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 }), - - 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), + + // 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 -- 2.49.1