diff --git a/app/frontend/App.tsx b/app/frontend/App.tsx index 3f82cb2..7955662 100644 --- a/app/frontend/App.tsx +++ b/app/frontend/App.tsx @@ -19,7 +19,6 @@ import Notes from "./components/Notes"; import NoteDetails from "./components/Note/NoteDetails"; import ProfileSettings from "./components/Profile/ProfileSettings"; import Layout from "./Layout"; -import { DataProvider } from "./contexts/DataContext"; import { User } from "./entities/User"; import TasksToday from "./components/Task/TasksToday"; @@ -101,7 +100,7 @@ const App: React.FC = () => { } return ( - + <> {currentUser ? ( { ) : ( )} - + > ); }; diff --git a/app/frontend/Layout.tsx b/app/frontend/Layout.tsx index ccb43d6..0eb6e9c 100644 --- a/app/frontend/Layout.tsx +++ b/app/frontend/Layout.tsx @@ -13,8 +13,13 @@ import { Area } from "./entities/Area"; import { Tag } from "./entities/Tag"; import { Project } from "./entities/Project"; import { Task } from "./entities/Task"; -import { useDataContext } from "./contexts/DataContext"; import { User } from "./entities/User"; +import { useStore } from "./store/useStore"; +import { fetchNotes, createNote, updateNote } from "./utils/notesService"; +import { fetchAreas, createArea, updateArea } from "./utils/areasService"; +import { fetchTags, createTag, updateTag } from "./utils/tagsService"; +import { fetchProjects, createProject, updateProject } from "./utils/projectsService"; +import { fetchTasks, createTask, updateTask } from "./utils/tasksService"; interface LayoutProps { currentUser: User; @@ -43,27 +48,47 @@ const Layout: React.FC = ({ const [newTask, setNewTask] = useState(null); const { - tags, - areas, - notes, - projects, - isLoading, - isError, - createNote, - updateNote, - deleteNote, - createArea, - updateArea, - deleteArea, - createTag, - updateTag, - deleteTag, - createProject, - updateProject, - deleteProject, - createTask, - updateTask, - } = useDataContext(); + notesStore: { + notes, + setNotes, + setLoading: setNotesLoading, + setError: setNotesError, + isLoading: isNotesLoading, + isError: isNotesError, + }, + areasStore: { + areas, + setAreas, + setLoading: setAreasLoading, + setError: setAreasError, + isLoading: isAreasLoading, + isError: isAreasError, + }, + tasksStore: { + tasks, + setTasks, + setLoading: setTasksLoading, + setError: setTasksError, + isLoading: isTasksLoading, + isError: isTasksError, + }, + projectsStore: { + projects, + setProjects, + setLoading: setProjectsLoading, + setError: setProjectsError, + isLoading: isProjectsLoading, + isError: isProjectsError, + }, + tagsStore: { + tags, + setTags, + setLoading: setTagsLoading, + setError: setTagsError, + isLoading: isTagsLoading, + isError: isTagsError, + }, + } = useStore(); const [isSidebarOpen, setIsSidebarOpen] = useState( window.innerWidth >= 1024 @@ -77,6 +102,37 @@ const Layout: React.FC = ({ return () => window.removeEventListener("resize", handleResize); }, []); + const loadNotes = async () => { + setNotesLoading(true); + try { + const notesData = await fetchNotes(); + setNotes(notesData); + } catch (error) { + console.error("Error fetching notes:", error); + setNotesError(true); + } finally { + setNotesLoading(false); + } + }; + + const loadAreas = async () => { + setAreasLoading(true); + try { + const areasData = await fetchAreas(); + setAreas(areasData); + } catch (error) { + console.error("Error fetching areas:", error); + setAreasError(true); + } finally { + setAreasLoading(false); + } + }; + + useEffect(() => { + loadNotes(); + loadAreas(); + }, []); + const openNoteModal = (note: Note | null = null) => { setSelectedNote(note); setIsNoteModalOpen(true); @@ -127,20 +183,11 @@ const Layout: React.FC = ({ const handleSaveNote = async (noteData: Note) => { try { if (noteData.id) { - await updateNote(noteData.id, { - title: noteData.title, - content: noteData.content, - tags: noteData.tags?.map((tag) => tag.name), - project_id: noteData.project?.id, - }); + await updateNote(noteData.id, noteData); } else { - await createNote({ - title: noteData.title, - content: noteData.content, - tags: noteData.tags?.map((tag) => tag.name), - project_id: noteData.project?.id, - }); + await createNote(noteData); } + loadNotes(); } catch (error) { console.error("Error saving note:", error); } @@ -154,12 +201,27 @@ const Layout: React.FC = ({ } else { await createTask(taskData); } + const { tasks } = await fetchTasks(); + setTasks(tasks); } catch (error) { console.error("Error saving task:", error); } closeTaskModal(); }; + const handleCreateProject = async (name: string): Promise => { + try { + const newProject = await createProject({ + name, + active: true, + }); + return newProject; + } catch (error) { + console.error("Error creating project:", error); + throw error; + } + }; + const handleSaveProject = async (projectData: Project) => { try { if (projectData.id) { @@ -167,29 +229,22 @@ const Layout: React.FC = ({ } else { await createProject(projectData); } + const projectsData = await fetchProjects(); + setProjects(projectsData); } catch (error) { console.error("Error saving project:", error); } closeProjectModal(); }; - const handleCreateProject = async (name: string): Promise => { - try { - const newProject = await createProject({ name }); - return newProject; - } catch (error) { - console.error("Error creating project:", error); - throw error; - } - }; - - const handleSaveArea = async (areaData: Area) => { + const handleSaveArea = async (areaData: Partial) => { try { if (areaData.id) { await updateArea(areaData.id, areaData); } else { await createArea(areaData); } + loadAreas(); } catch (error) { console.error("Error saving area:", error); } @@ -203,6 +258,8 @@ const Layout: React.FC = ({ } else { await createTag(tagData); } + const tagsData = await fetchTags(); + setTags(tagsData); } catch (error) { console.error("Error saving tag:", error); } @@ -211,6 +268,19 @@ const Layout: React.FC = ({ const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0"; + const isLoading = + isNotesLoading || + isAreasLoading || + isTasksLoading || + isProjectsLoading || + isTagsLoading; + const isError = + isNotesError || + isAreasError || + isTasksError || + isProjectsError || + isTagsError; + if (isLoading) { return ( @@ -357,7 +427,7 @@ const Layout: React.FC = ({ } onSave={handleSaveTask} onDelete={() => {}} - projects={projects} + projects={projects} onCreateProject={handleCreateProject} /> )} @@ -402,4 +472,3 @@ const Layout: React.FC = ({ }; export default Layout; - diff --git a/app/frontend/components/Area/AreaDetails.tsx b/app/frontend/components/Area/AreaDetails.tsx index f3d0311..0335601 100644 --- a/app/frontend/components/Area/AreaDetails.tsx +++ b/app/frontend/components/Area/AreaDetails.tsx @@ -1,15 +1,23 @@ import React, { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { useDataContext } from '../../contexts/DataContext'; +import { useStore } from '../../store/useStore'; +import { Area } from '../../entities/Area'; const AreaDetails: React.FC = () => { const { id } = useParams<{ id: string }>(); - const { areas, isLoading, isError } = useDataContext(); - const [area, setArea] = useState(null); + const { areas } = useStore((state) => state.areasStore); + const [area, setArea] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); useEffect(() => { - const foundArea = areas.find((a) => a.id === Number(id)); + if (!areas.length) setIsLoading(true); + const foundArea = areas.find((a: Area) => a.id === Number(id)); setArea(foundArea || null); + if (!foundArea) { + setIsError(true); + } + setIsLoading(false); }, [id, areas]); if (isLoading) { @@ -25,7 +33,7 @@ const AreaDetails: React.FC = () => { if (isError || !area) { return ( - + {isError ? 'Error loading area details.' : 'Area not found.'} @@ -50,4 +58,4 @@ const AreaDetails: React.FC = () => { ); }; -export default AreaDetails; +export default AreaDetails; \ No newline at end of file diff --git a/app/frontend/components/Area/AreaModal.tsx b/app/frontend/components/Area/AreaModal.tsx index 4959b4c..3274061 100644 --- a/app/frontend/components/Area/AreaModal.tsx +++ b/app/frontend/components/Area/AreaModal.tsx @@ -1,29 +1,27 @@ import React, { useState, useEffect, useRef } from 'react'; import { Area } from '../../entities/Area'; -import { useDataContext } from '../../contexts/DataContext'; -import { XMarkIcon } from '@heroicons/react/24/outline'; import { useToast } from '../Shared/ToastContext'; interface AreaModalProps { isOpen: boolean; onClose: () => void; - onSave: (areaData: Area) => void; + onSave: (areaData: Partial) => Promise; area?: Area | null; } const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) => { - const { createArea, updateArea } = useDataContext(); const [formData, setFormData] = useState({ id: area?.id || 0, name: area?.name || '', description: area?.description || '', }); + const [error, setError] = useState(null); const modalRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [isClosing, setIsClosing] = useState(false); - const { showSuccessToast, showErrorToast } = useToast(); + const { showSuccessToast, showErrorToast } = useToast(); useEffect(() => { if (isOpen) { @@ -60,6 +58,7 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) handleClose(); } }; + if (isOpen) { document.addEventListener('keydown', handleKeyDown); } @@ -88,14 +87,8 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) setError(null); try { - if (formData.id && formData.id !== 0) { - await updateArea(formData.id, formData); - showSuccessToast('Area updated successfully!'); - } else { - await createArea(formData); - showSuccessToast('Area created successfully!'); - } - onSave(formData); + await onSave(formData); + showSuccessToast(`Area ${formData.id ? 'updated' : 'created'} successfully!`); handleClose(); } catch (err) { setError((err as Error).message); @@ -116,88 +109,80 @@ const AreaModal: React.FC = ({ isOpen, onClose, area, onSave }) if (!isOpen) return null; return ( - <> + - - - - - {/* Area Name */} - - - - - {/* Area Description */} - - - Description - - - - - {/* Error Message */} - {error && {error}} + + + + {/* Area Name */} + + - {/* Action Buttons */} - - - Cancel - - - {isSubmitting - ? 'Submitting...' - : formData.id && formData.id !== 0 - ? 'Update Area' - : 'Create Area'} - + {/* Area Description */} + + + Description + + - - - + + {/* Error Message */} + {error && {error}} + + + {/* Action Buttons */} + + + Cancel + + + {isSubmitting + ? 'Submitting...' + : formData.id && formData.id !== 0 + ? 'Update Area' + : 'Create Area'} + + + + - > + ); }; -export default AreaModal; +export default AreaModal; \ No newline at end of file diff --git a/app/frontend/components/Areas.tsx b/app/frontend/components/Areas.tsx index 16e1407..db5ad22 100644 --- a/app/frontend/components/Areas.tsx +++ b/app/frontend/components/Areas.tsx @@ -1,23 +1,40 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { PencilSquareIcon, TrashIcon, Squares2X2Icon, -} from '@heroicons/react/24/solid'; +} from '@heroicons/react/24/solid'; import ConfirmDialog from './Shared/ConfirmDialog'; -import AreaModal from './Area/AreaModal'; -import { useDataContext } from '../contexts/DataContext'; +import AreaModal from './Area/AreaModal'; +import { useStore } from '../store/useStore'; +import { fetchAreas, createArea, updateArea, deleteArea } from '../utils/areasService'; import { Area } from '../entities/Area'; const Areas: React.FC = () => { - const { areas, isLoading, isError, createArea, updateArea, deleteArea } = useDataContext(); + const { areas, setAreas, setLoading, setError } = useStore((state) => state.areasStore); + const [isAreaModalOpen, setIsAreaModalOpen] = useState(false); const [selectedArea, setSelectedArea] = useState(null); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [areaToDelete, setAreaToDelete] = useState(null); - const handleSaveArea = async (areaData: Area) => { + useEffect(() => { + const loadAreas = async () => { + try { + const areasData = await fetchAreas(); + setAreas(areasData); + } catch (error) { + console.error('Error fetching areas:', error); + setError(true); + } + }; + + loadAreas(); + }, []); + + const handleSaveArea = async (areaData: Partial) => { + setLoading(true); try { if (areaData.id) { await updateArea(areaData.id, { @@ -30,9 +47,13 @@ const Areas: React.FC = () => { description: areaData.description, }); } + const updatedAreas = await fetchAreas(); + setAreas(updatedAreas); } catch (error) { console.error('Error saving area:', error); + setError(true); } finally { + setLoading(false); setIsAreaModalOpen(false); setSelectedArea(null); } @@ -56,12 +77,18 @@ const Areas: React.FC = () => { const handleDeleteArea = async () => { if (!areaToDelete) return; + setLoading(true); try { await deleteArea(areaToDelete.id!); + const updatedAreas = await fetchAreas(); + setAreas(updatedAreas); setIsConfirmDialogOpen(false); setAreaToDelete(null); } catch (error) { console.error('Error deleting area:', error); + setError(true); + } finally { + setLoading(false); } }; @@ -70,26 +97,8 @@ const Areas: React.FC = () => { setAreaToDelete(null); }; - if (isLoading) { - return ( - - - Loading areas... - - - ); - } - - if (isError) { - return ( - - An error occurred while fetching areas. - - ); - } - return ( - + {/* Areas Header */} @@ -99,6 +108,12 @@ const Areas: React.FC = () => { Areas + + Add Area + {/* Areas List */} @@ -174,4 +189,4 @@ const Areas: React.FC = () => { ); }; -export default Areas; +export default Areas; \ No newline at end of file diff --git a/app/frontend/components/Note/NoteDetails.tsx b/app/frontend/components/Note/NoteDetails.tsx index 3461943..405139c 100644 --- a/app/frontend/components/Note/NoteDetails.tsx +++ b/app/frontend/components/Note/NoteDetails.tsx @@ -1,43 +1,67 @@ import React, { useEffect, useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid'; -import { useDataContext } from '../../contexts/DataContext'; import ConfirmDialog from '../Shared/ConfirmDialog'; import NoteModal from './NoteModal'; -import { Note } from '../../entities/Note'; +import { Note } from '../../entities/Note'; +import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService'; const NoteDetails: React.FC = () => { const { id } = useParams<{ id: string }>(); - const { notes, deleteNote, isLoading, isError } = useDataContext(); const [note, setNote] = useState(null); - const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); + const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); - + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); const navigate = useNavigate(); useEffect(() => { - const foundNote = notes.find((n) => n.id === Number(id)); - setNote(foundNote || null); - }, [id, notes]); + const fetchNote = async () => { + try { + setIsLoading(true); + const notes = await fetchNotes(); + const foundNote = notes.find((n: Note) => n.id === Number(id)); + setNote(foundNote || null); + if (!foundNote) { + setIsError(true); + } + } catch (err) { + setIsError(true); + console.error('Error fetching note:', err); + } finally { + setIsLoading(false); + } + }; + fetchNote(); + }, [id]); const handleDeleteNote = async () => { if (!noteToDelete) return; try { - await deleteNote(noteToDelete.id!); - navigate('/notes'); + await apiDeleteNote(noteToDelete.id!); + navigate('/notes'); } catch (err) { console.error('Error deleting note:', err); } }; - const handleSaveNote = (updatedNote: Note) => { - setNote(updatedNote); - setIsNoteModalOpen(false); + const handleSaveNote = async (updatedNote: Note) => { + try { + if (updatedNote.id !== undefined) { + await apiUpdateNote(updatedNote.id, updatedNote); + setNote(updatedNote); + } else { + console.error("Error: Note ID is undefined."); + } + } catch (err) { + console.error('Error saving note:', err); + } + setIsNoteModalOpen(false); }; const handleEditNote = () => { - setIsNoteModalOpen(true); + setIsNoteModalOpen(true); }; const handleOpenConfirmDialog = (note: Note) => { @@ -66,7 +90,7 @@ const NoteDetails: React.FC = () => { } return ( - + {/* Header Section with Title and Action Buttons */} @@ -76,7 +100,6 @@ const NoteDetails: React.FC = () => { {note.title} - {/* Action Buttons */} { - {/* Card with Tags and Metadata */} {/* Note Tags */} @@ -111,23 +133,25 @@ const NoteDetails: React.FC = () => { className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600" > - {tag.name} + + {tag.name} + ))} )} - {/* Note Metadata */} Created on: {new Date(note.created_at || '').toLocaleDateString()} Last updated: {new Date(note.updated_at || '').toLocaleDateString()} - {/* Note Project */} {note.project && ( - Project + + Project + { )} - {/* Note Content */} {note.content} - {/* NoteModal for editing */} {isNoteModalOpen && ( { note={note} /> )} - {/* ConfirmDialog */} {isConfirmDialogOpen && noteToDelete && ( { ); }; -export default NoteDetails; +export default NoteDetails; \ No newline at end of file diff --git a/app/frontend/components/Note/NoteModal.tsx b/app/frontend/components/Note/NoteModal.tsx index b564afe..cbc243b 100644 --- a/app/frontend/components/Note/NoteModal.tsx +++ b/app/frontend/components/Note/NoteModal.tsx @@ -1,19 +1,18 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Note } from '../../entities/Note'; -import { useDataContext } from '../../contexts/DataContext'; import { useToast } from '../Shared/ToastContext'; import TagInput from '../Tag/TagInput'; import { Tag } from '../../entities/Tag'; +import { fetchTags } from '../../utils/tagsService'; interface NoteModalProps { isOpen: boolean; onClose: () => void; note?: Note | null; - onSave: (noteData: Note) => void; + onSave: (noteData: Note) => Promise; } const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) => { - const { createNote, updateNote } = useDataContext(); const [formData, setFormData] = useState({ id: note?.id || 0, title: note?.title || '', @@ -30,14 +29,18 @@ const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) const { showSuccessToast, showErrorToast } = useToast(); useEffect(() => { + const loadTags = async () => { + try { + const data = await fetchTags(); + setAvailableTags(data); + } catch (error) { + console.error('Failed to fetch tags', error); + showErrorToast('Failed to load available tags.'); + } + }; + if (isOpen) { - fetch('/api/tags') - .then((response) => response.json()) - .then((data: Tag[]) => setAvailableTags(data)) - .catch((error) => { - console.error('Failed to fetch tags', error); - showErrorToast('Failed to load available tags.'); - }); + loadTags(); } }, [isOpen, showErrorToast]); @@ -78,6 +81,7 @@ const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) handleClose(); } }; + if (isOpen) { document.addEventListener('keydown', handleKeyDown); } @@ -114,14 +118,9 @@ const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) setError(null); try { - if (formData.id && formData.id !== 0) { - await updateNote(formData.id, { ...formData, tags }); - showSuccessToast('Note updated successfully!'); - } else { - await createNote({ ...formData, tags }); - showSuccessToast('Note created successfully!'); - } - onSave(formData); + const noteTags: Tag[] = tags.map(tagName => ({ name: tagName })); + await onSave({ ...formData, tags: noteTags }); + showSuccessToast(formData.id && formData.id !== 0 ? 'Note updated successfully!' : 'Note created successfully!'); handleClose(); } catch (err) { setError((err as Error).message); @@ -142,97 +141,95 @@ const NoteModal: React.FC = ({ isOpen, onClose, note, onSave }) if (!isOpen) return null; return ( - <> + - - - - - - + + + + + + + + + Tags + + + - - - - Tags - - - - - - - - - Content - - - - - {error && {error}} - - - Cancel - - - {isSubmitting - ? 'Submitting...' - : formData.id && formData.id !== 0 - ? 'Update Note' - : 'Create Note'} - + + + Content + + - - - + + {error && {error}} + + + + + Cancel + + + {isSubmitting + ? 'Submitting...' + : formData.id && formData.id !== 0 + ? 'Update Note' + : 'Create Note'} + + + + - > + ); }; -export default NoteModal; +export default NoteModal; \ No newline at end of file diff --git a/app/frontend/components/Notes.tsx b/app/frontend/components/Notes.tsx index b08b7ed..d947456 100644 --- a/app/frontend/components/Notes.tsx +++ b/app/frontend/components/Notes.tsx @@ -1,23 +1,53 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { BookOpenIcon, PencilSquareIcon, TrashIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid'; +import { + BookOpenIcon, + PencilSquareIcon, + TrashIcon, + MagnifyingGlassIcon, +} from '@heroicons/react/24/solid'; import NoteModal from './Note/NoteModal'; import ConfirmDialog from './Shared/ConfirmDialog'; -import { useDataContext } from '../contexts/DataContext'; import { Note } from '../entities/Note'; +import { + fetchNotes, + createNote, + updateNote, + deleteNote as apiDeleteNote, +} from '../utils/notesService'; const Notes: React.FC = () => { - const { notes, createNote, updateNote, deleteNote, isLoading, isError } = useDataContext(); + const [notes, setNotes] = useState([]); const [selectedNote, setSelectedNote] = useState(null); const [isNoteModalOpen, setIsNoteModalOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + const loadNotes = async () => { + setIsLoading(true); + try { + const fetchedNotes = await fetchNotes(); + setNotes(fetchedNotes); + } catch (error) { + console.error('Error loading notes:', error); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + loadNotes(); + }, []); const handleDeleteNote = async () => { if (!noteToDelete) return; try { - await deleteNote(noteToDelete.id!); + await apiDeleteNote(noteToDelete.id!); + setNotes((prev) => prev.filter((note) => note.id !== noteToDelete.id)); setIsConfirmDialogOpen(false); setNoteToDelete(null); } catch (err) { @@ -32,11 +62,17 @@ const Notes: React.FC = () => { const handleSaveNote = async (noteData: Note) => { try { + let updatedNotes; if (noteData.id) { await updateNote(noteData.id, noteData); + updatedNotes = notes.map((note) => + note.id === noteData.id ? noteData : note + ); } else { - await createNote(noteData); + const newNote = await createNote(noteData); + updatedNotes = [...notes, newNote]; } + setNotes(updatedNotes); setIsNoteModalOpen(false); setSelectedNote(null); } catch (err) { @@ -69,13 +105,15 @@ const Notes: React.FC = () => { } return ( - + {/* Notes Header */} - Notes + + Notes + @@ -99,7 +137,10 @@ const Notes: React.FC = () => { ) : ( {filteredNotes.map((note) => ( - + { ); }; -export default Notes; +export default Notes; \ No newline at end of file diff --git a/app/frontend/components/Project/ProjectDetails.tsx b/app/frontend/components/Project/ProjectDetails.tsx index 921f1b3..b2318ba 100644 --- a/app/frontend/components/Project/ProjectDetails.tsx +++ b/app/frontend/components/Project/ProjectDetails.tsx @@ -4,15 +4,19 @@ import { PencilSquareIcon, TrashIcon, FolderIcon, - Squares2X2Icon, + Squares2X2Icon } from "@heroicons/react/24/outline"; import TaskList from "../Task/TaskList"; import ProjectModal from "../Project/ProjectModal"; import ConfirmDialog from "../Shared/ConfirmDialog"; -import { useDataContext } from "../../contexts/DataContext"; +import { useStore } from "../../store/useStore"; import NewTask from "../Task/NewTask"; import { Project } from "../../entities/Project"; import { PriorityType, Task } from "../../entities/Task"; +import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService"; +import { createTask, updateTask, deleteTask } from "../../utils/tasksService"; +import { fetchAreas } from "../../utils/areasService"; +import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid"; type PriorityStyles = Record & { default: string }; @@ -24,11 +28,10 @@ const priorityStyles: PriorityStyles = { }; const ProjectDetails: React.FC = () => { - const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext(); const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - - const { areas } = useDataContext(); + + const areas = useStore((state) => state.areasStore.areas); const [project, setProject] = useState(undefined); const [tasks, setTasks] = useState([]); @@ -36,71 +39,51 @@ const ProjectDetails: React.FC = () => { const [error, setError] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - - const projectTitle = project?.name || "Project"; - const [isCompletedOpen, setIsCompletedOpen] = useState(false); useEffect(() => { - const fetchProject = async () => { + const loadProjectData = async () => { + if (!id) { + console.error("Project ID is missing."); + return; + } + + setLoading(true); try { - const response = await fetch(`/api/project/${id}`, { - credentials: "include", - headers: { Accept: "application/json" }, - }); - const data = await response.json(); - if (response.ok) { - setProject(data); - setTasks(data.tasks || []); - } else { - throw new Error(data.error || "Failed to fetch project."); - } + fetchAreas(); + const projectData = await fetchProjectById(id); + setProject(projectData); + setTasks(projectData.tasks || []); } catch (error) { - setError((error as Error).message); + console.error("Error fetching project data:", error); } finally { setLoading(false); } }; - - fetchProject(); - }, [id]); + + loadProjectData(); + }, [id, fetchAreas]); const handleTaskCreate = async (taskName: string) => { - if (!project || project.id === undefined) { - console.error("Cannot create task: Project or Project ID is missing"); + if (!project) { + console.error("Cannot create task: Project is missing"); return; } - const taskPayload = { - name: taskName, - status: "not_started", - project_id: project.id, - }; - try { - const response = await fetch(`/api/task`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - credentials: "include", - body: JSON.stringify(taskPayload), + const newTask = await createTask({ + name: taskName, + status: "not_started", + project_id: project.id, }); - - const newTask = await response.json(); - if (response.ok) { - setTasks([...tasks, newTask]); - } else { - throw new Error(newTask.error || "Failed to create task"); - } + setTasks((prevTasks) => [...prevTasks, newTask]); } catch (err) { console.error("Error creating task:", err); } }; const handleTaskUpdate = async (updatedTask: Task) => { - if (updatedTask.id === undefined) { + if (!updatedTask.id) { console.error("Cannot update task: Task ID is missing"); return; } @@ -117,7 +100,7 @@ const ProjectDetails: React.FC = () => { }; const handleTaskDelete = async (taskId: number | undefined) => { - if (taskId === undefined) { + if (!taskId) { console.error("Cannot delete task: Task ID is missing"); return; } @@ -134,11 +117,11 @@ const ProjectDetails: React.FC = () => { }; const handleSaveProject = async (updatedProject: Project) => { - if (!updatedProject || updatedProject.id === undefined) { - console.error("Cannot save project: Project or Project ID is missing"); + if (!updatedProject.id) { + console.error("Cannot save project: Project ID is missing"); return; } - + try { const savedProject = await updateProject(updatedProject.id, updatedProject); setProject(savedProject); @@ -149,8 +132,8 @@ const ProjectDetails: React.FC = () => { }; const handleDeleteProject = async () => { - if (!project || project.id === undefined) { - console.error("Cannot delete project: Project or Project ID is missing"); + if (!project?.id) { + console.error("Cannot delete project: Project ID is missing"); return; } @@ -188,9 +171,9 @@ const ProjectDetails: React.FC = () => { ); } - const activeTasks = tasks.filter(task => task.status !== 'done'); - const completedTasks = tasks.filter(task => task.status === 'done'); - + const activeTasks = tasks?.filter((task) => task.status !== 'done') || []; //TODO: Also add archived + const completedTasks = tasks?.filter((task) => task.status === 'done'); + const toggleCompleted = () => { setIsCompletedOpen(!isCompletedOpen); }; @@ -201,29 +184,27 @@ const ProjectDetails: React.FC = () => { {/* Project Header */} - + - {projectTitle} + {project.name} - {/* Priority Circle placed after the title */} {project.priority && ( )} - {/* Edit Project Button */} - - {/* Delete Project Button */} setIsConfirmDialogOpen(true)} className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none" @@ -233,9 +214,8 @@ const ProjectDetails: React.FC = () => { - {/* Project Area */} {project.area && ( - + { )} - {/* Project Description */} + {project.due_date_at && ( + + + {project.due_date_at} + + )} + {project.description && ( - + + {project.description} )} - {/* New Task Form */} - + - {/* Active Tasks */} {activeTasks.length > 0 ? ( { )} - {/* Collapsible Completed Tasks */} { {isCompletedOpen && ( - {completedTasks.length > 0 ? ( + {completedTasks && completedTasks.length > 0 ? ( { )} - {/* Modals */} setIsModalOpen(false)} @@ -323,7 +304,6 @@ const ProjectDetails: React.FC = () => { areas={areas} /> - {/* Confirm Delete Dialog */} {isConfirmDialogOpen && ( { } }; -export default ProjectDetails; +export default ProjectDetails; \ No newline at end of file diff --git a/app/frontend/components/Project/ProjectModal.tsx b/app/frontend/components/Project/ProjectModal.tsx index 6e014cc..7f0355f 100644 --- a/app/frontend/components/Project/ProjectModal.tsx +++ b/app/frontend/components/Project/ProjectModal.tsx @@ -4,10 +4,11 @@ import { Project } from "../../entities/Project"; import ConfirmDialog from "../Shared/ConfirmDialog"; import { useToast } from "../Shared/ToastContext"; import TagInput from "../Tag/TagInput"; -import useFetchTags from "../../hooks/useFetchTags"; import PriorityDropdown from "../Shared/PriorityDropdown"; import { PriorityType } from "../../entities/Task"; import Switch from "../Shared/Switch"; +import { useStore } from "../../store/useStore"; +import { fetchTags } from "../../utils/tagsService"; interface ProjectModalProps { isOpen: boolean; @@ -34,6 +35,7 @@ const ProjectModal: React.FC = ({ active: true, tags: [], priority: "low", + due_date_at: "", } ); @@ -41,23 +43,21 @@ const ProjectModal: React.FC = ({ project?.tags?.map((tag) => tag.name) || [] ); - const { - tags: availableTags, - isLoading: isTagsLoading, - isError: isTagsError, - } = useFetchTags(); + const { tagsStore } = useStore(); + const { tags: availableTags } = tagsStore; const modalRef = useRef(null); const [isClosing, setIsClosing] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false); - const { showSuccessToast, showErrorToast } = useToast(); + const { showSuccessToast } = useToast(); useEffect(() => { if (project) { setFormData({ ...project, tags: project.tags || [], + due_date_at: project.due_date_at || "", }); setTags(project.tags?.map((tag) => tag.name) || []); } else { @@ -67,11 +67,19 @@ const ProjectModal: React.FC = ({ area_id: null, active: true, tags: [], + priority: "low", + due_date_at: "", }); setTags([]); } }, [project]); + useEffect(() => { + if (availableTags.length === 0) { + fetchTags(); + } + }, [availableTags.length]); + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -176,35 +184,13 @@ const ProjectModal: React.FC = ({ if (!isOpen) return null; - if (isTagsLoading) { - return ( - - - Loading tags... - - - ); - } - - if (isTagsError) { - return ( - - - Error loading tags. - - - ); - } - return ( <> - {/* Modal Overlay */} - {/* Modal Content */} = ({ maxHeight: "calc(100vh - 4rem)", }} > - {/* Form */} - {/* Form Fields */} - {/* Project Name */} = ({ /> - {/* Description */} Description @@ -249,6 +231,19 @@ const ProjectModal: React.FC = ({ > + + + Due Date + + + + Priority @@ -261,7 +256,6 @@ const ProjectModal: React.FC = ({ /> - {/* Tags */} Tags @@ -275,7 +269,6 @@ const ProjectModal: React.FC = ({ - {/* Area */} Area (optional) @@ -296,7 +289,6 @@ const ProjectModal: React.FC = ({ - {/* Active Checkbox */} = ({ - {/* Action Buttons */} {project && onDelete && ( = ({ - {/* Confirmation Dialog for Deletion */} {showConfirmDialog && ( = ({ ); }; -export default ProjectModal; +export default ProjectModal; \ No newline at end of file diff --git a/app/frontend/components/Projects.tsx b/app/frontend/components/Projects.tsx index b176634..a23d6b5 100644 --- a/app/frontend/components/Projects.tsx +++ b/app/frontend/components/Projects.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect } from "react"; -import { Project } from "../entities/Project"; import { MagnifyingGlassIcon, FolderIcon, @@ -8,8 +7,11 @@ import { } from "@heroicons/react/24/solid"; import ConfirmDialog from "./Shared/ConfirmDialog"; import ProjectModal from "./Project/ProjectModal"; -import { useDataContext } from "../contexts/DataContext"; -import useFetchProjects from "../hooks/useFetchProjects"; +import { useStore } from "../store/useStore"; +import { fetchProjects, createProject, updateProject, deleteProject } from "../utils/projectsService"; +import { fetchAreas } from "../utils/areasService"; + +import { Project } from "../entities/Project"; import { PriorityType, StatusType } from "../entities/Task"; import { useSearchParams } from "react-router-dom"; import ProjectItem from "./Project/ProjectItem"; @@ -30,7 +32,10 @@ const getPriorityStyles = (priority: PriorityType) => { }; const Projects: React.FC = () => { - const { areas, createProject, updateProject, deleteProject } = useDataContext(); + const { areas, setAreas, setLoading: setAreasLoading, setError: setAreasError } = useStore((state) => state.areasStore); + const { projects, setProjects, setLoading: setProjectsLoading, setError: setProjectsError } = useStore((state) => state.projectsStore); + const { isLoading, isError } = useStore((state) => state.projectsStore); + const [taskStatusCounts, setTaskStatusCounts] = useState>({}); const [isProjectModalOpen, setIsProjectModalOpen] = useState(false); const [projectToEdit, setProjectToEdit] = useState(null); @@ -44,17 +49,79 @@ const Projects: React.FC = () => { const activeFilter = searchParams.get("active") || "all"; const areaFilter = searchParams.get("area_id") || ""; - const { - projects, - taskStatusCounts: fetchedTaskStatusCounts, - isLoading, - isError, - mutate, - } = useFetchProjects({ activeFilter, areaFilter }); +useEffect(() => { + const loadAreas = async () => { + try { + const areasData = await fetchAreas(); + setAreas(areasData); + } catch (error) { + console.error("Failed to fetch areas:", error); + setAreasError(true); + } + }; - useEffect(() => { - setTaskStatusCounts(fetchedTaskStatusCounts || {}); - }, [fetchedTaskStatusCounts]); + loadAreas(); +}, []); + +useEffect(() => { + const loadProjects = async () => { + try { + const projectsData = await fetchProjects(activeFilter, areaFilter); + setProjects(projectsData); + } catch (error) { + console.error("Failed to fetch projects:", error); + setProjectsError(true); + } + }; + + loadProjects(); +}, [activeFilter, areaFilter]); + + const handleSaveProject = async (project: Project) => { + setProjectsLoading(true); + try { + if (project.id) { + await updateProject(project.id, project); + } else { + await createProject(project); + } + const updatedProjects = await fetchProjects(activeFilter, areaFilter); + setProjects(updatedProjects); + } catch (error) { + console.error("Error saving project:", error); + setProjectsError(true); + } finally { + setProjectsLoading(false); + setIsProjectModalOpen(false); + } + }; + + const handleEditProject = (project: Project) => { + setProjectToEdit(project); + setIsProjectModalOpen(true); + }; + + const handleDeleteProject = async () => { + if (!projectToDelete) return; + + try { + if (projectToDelete.id !== undefined) { + setProjectsLoading(true); + await deleteProject(projectToDelete.id); + const updatedProjects = await fetchProjects(activeFilter, areaFilter); + setProjects(updatedProjects); + } else { + console.error("Cannot delete project: ID is undefined."); + } + } catch (error) { + console.error("Error deleting project:", error); + setProjectsError(true); + } finally { + setProjectsLoading(false); + setIsConfirmDialogOpen(false); + setProjectToDelete(null); + } + }; const getCompletionPercentage = (projectId: number | undefined) => { if (!projectId) return 0; @@ -69,29 +136,6 @@ const Projects: React.FC = () => { return Math.round((taskStatus.done / totalTasks) * 100); }; - const handleSaveProject = async (project: Project) => { - if (project.id) { - await updateProject(project.id, project); - } else { - await createProject(project); - } - setIsProjectModalOpen(false); - mutate(); - }; - - const handleEditProject = (project: Project) => { - setProjectToEdit(project); - setIsProjectModalOpen(true); - }; - - const handleDeleteProject = async () => { - if (!projectToDelete) return; - await deleteProject(projectToDelete.id!); - setIsConfirmDialogOpen(false); - setProjectToDelete(null); - mutate(); - }; - const handleActiveFilterChange = (e: React.ChangeEvent) => { const newActiveFilter = e.target.value; const params = new URLSearchParams(searchParams); @@ -121,6 +165,16 @@ const Projects: React.FC = () => { project.name.toLowerCase().includes(searchQuery.toLowerCase()) ); + const groupedProjects = filteredProjects.reduce>( + (acc, project) => { + const areaName = project.area ? project.area.name : "Uncategorized"; + if (!acc[areaName]) acc[areaName] = []; + acc[areaName].push(project); + return acc; + }, + {} + ); + if (isLoading) { return ( @@ -139,16 +193,6 @@ const Projects: React.FC = () => { ); } - const groupedProjects = filteredProjects.reduce>( - (acc, project) => { - const areaName = project.area ? project.area.name : "Uncategorized"; - if (!acc[areaName]) acc[areaName] = []; - acc[areaName].push(project); - return acc; - }, - {} - ); - return ( @@ -222,7 +266,7 @@ const Projects: React.FC = () => { > All Areas {areas.map((area) => ( - + {area.name} ))} @@ -250,7 +294,7 @@ const Projects: React.FC = () => { className={`${ viewMode === "cards" ? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4" - : "flex flex-col space-y-4" + : "flex flex-col space-y-1" }`} > {Object.keys(groupedProjects).length === 0 ? ( diff --git a/app/frontend/components/Shared/Switch.tsx b/app/frontend/components/Shared/Switch.tsx index be45fa3..88cdcaf 100644 --- a/app/frontend/components/Shared/Switch.tsx +++ b/app/frontend/components/Shared/Switch.tsx @@ -1,4 +1,3 @@ -// Switch.tsx import React from 'react'; interface SwitchProps { diff --git a/app/frontend/components/Tag/TagInput.tsx b/app/frontend/components/Tag/TagInput.tsx index 6d7f2e7..ceb6a76 100644 --- a/app/frontend/components/Tag/TagInput.tsx +++ b/app/frontend/components/Tag/TagInput.tsx @@ -85,7 +85,7 @@ const TagInput: React.FC = ({ initialTags, onTagsChange, availabl }; const addNewTag = (tag: string) => { - if (tags.length >= 10) { // Example limit + if (tags.length >= 10) { return; } diff --git a/app/frontend/components/Tags.tsx b/app/frontend/components/Tags.tsx index fd7961e..ce14b28 100644 --- a/app/frontend/components/Tags.tsx +++ b/app/frontend/components/Tags.tsx @@ -1,23 +1,43 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import ConfirmDialog from './Shared/ConfirmDialog'; import TagModal from './Tag/TagModal'; -import { useDataContext } from '../contexts/DataContext'; import { Tag } from '../entities/Tag'; +import { fetchTags, createTag, updateTag, deleteTag as apiDeleteTag } from '../utils/tagsService'; const Tags: React.FC = () => { - const { tags, createTag, updateTag, deleteTag, isLoading, isError } = useDataContext(); + const [tags, setTags] = useState([]); const [isTagModalOpen, setIsTagModalOpen] = useState(false); const [selectedTag, setSelectedTag] = useState(null); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [tagToDelete, setTagToDelete] = useState(null); const [searchQuery, setSearchQuery] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + + useEffect(() => { + const loadTags = async () => { + setIsLoading(true); + try { + const fetchedTags = await fetchTags(); + setTags(fetchedTags); + } catch (error) { + console.error('Failed to fetch tags:', error); + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + loadTags(); + }, []); const handleDeleteTag = async () => { if (!tagToDelete) return; try { - await deleteTag(tagToDelete.id!); + await apiDeleteTag(tagToDelete.id!); + setTags((prev) => prev.filter((tag) => tag.id !== tagToDelete.id)); setIsConfirmDialogOpen(false); setTagToDelete(null); } catch (err) { @@ -32,17 +52,20 @@ const Tags: React.FC = () => { const handleSaveTag = async (tagData: Tag) => { try { + let updatedTags; if (tagData.id) { await updateTag(tagData.id, tagData); + updatedTags = tags.map((tag) => (tag.id === tagData.id ? tagData : tag)); } else { - await createTag(tagData); + const newTag = await createTag(tagData); + updatedTags = [...tags, newTag]; } + setTags(updatedTags); + setIsTagModalOpen(false); + setSelectedTag(null); } catch (err) { console.error('Failed to save tag:', err); } - - setIsTagModalOpen(false); - setSelectedTag(null); }; const openConfirmDialog = (tag: Tag) => { @@ -55,9 +78,8 @@ const Tags: React.FC = () => { setTagToDelete(null); }; - const filteredTags = tags.filter( - (tag) => - tag.name.toLowerCase().includes(searchQuery.toLowerCase()) + const filteredTags = tags.filter((tag) => + tag.name.toLowerCase().includes(searchQuery.toLowerCase()) ); if (isLoading) { @@ -71,11 +93,11 @@ const Tags: React.FC = () => { } if (isError) { - return Error loading tags; + return Error loading tags.; } return ( - + {/* Tags Header */} @@ -109,7 +131,6 @@ const Tags: React.FC = () => { key={tag.id} className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center" > - {/* Tag Content */} { - {/* Action Buttons */} handleEditTag(tag)} @@ -167,4 +187,4 @@ const Tags: React.FC = () => { ); }; -export default Tags; +export default Tags; \ No newline at end of file diff --git a/app/frontend/components/Task/TaskItem.tsx b/app/frontend/components/Task/TaskItem.tsx index 60adf7e..d7855ca 100644 --- a/app/frontend/components/Task/TaskItem.tsx +++ b/app/frontend/components/Task/TaskItem.tsx @@ -28,7 +28,7 @@ const TaskItem: React.FC = ({ onTaskUpdate(updatedTask); setIsModalOpen(false); }; - + const handleDelete = () => { if (task.id) { onTaskDelete(task.id); diff --git a/app/frontend/components/Task/TaskList.tsx b/app/frontend/components/Task/TaskList.tsx index 7209bfa..48d814d 100644 --- a/app/frontend/components/Task/TaskList.tsx +++ b/app/frontend/components/Task/TaskList.tsx @@ -14,7 +14,6 @@ interface TaskListProps { const TaskList: React.FC = ({ tasks, onTaskUpdate, - onTaskCreate, onTaskDelete, projects, }) => { diff --git a/app/frontend/components/Task/TaskModal.tsx b/app/frontend/components/Task/TaskModal.tsx index 1f2222c..9955c8f 100644 --- a/app/frontend/components/Task/TaskModal.tsx +++ b/app/frontend/components/Task/TaskModal.tsx @@ -7,8 +7,8 @@ import ConfirmDialog from "../Shared/ConfirmDialog"; import { useToast } from "../Shared/ToastContext"; import TagInput from "../Tag/TagInput"; import { Project } from "../../entities/Project"; -import { Tag } from "../../entities/Tag"; -import useFetchTags from "../../hooks/useFetchTags"; +import { useStore } from "../../store/useStore"; +import { fetchTags } from '../../utils/tagsService'; interface TaskModalProps { isOpen: boolean; @@ -30,36 +30,50 @@ const TaskModal: React.FC = ({ onCreateProject, }) => { const [formData, setFormData] = useState(task); - const [tags, setTags] = useState( - task.tags?.map((tag) => tag.name) || [] - ); + const [tags, setTags] = useState(task.tags?.map((tag) => tag.name) || []); const [filteredProjects, setFilteredProjects] = useState(projects); const [newProjectName, setNewProjectName] = useState(""); const [isCreatingProject, setIsCreatingProject] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const modalRef = useRef(null); const [isClosing, setIsClosing] = useState(false); - const [showConfirmDialog, setShowConfirmDialog] = useState(false); - - const { showSuccessToast, showErrorToast } = useToast(); - - const { tags: availableTags, isLoading, isError } = useFetchTags(); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const { showSuccessToast, showErrorToast } = useToast(); + const { tagsStore } = useStore(); + const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore; useEffect(() => { setFormData(task); setTags(task.tags?.map((tag) => tag.name) || []); - }, [task]); + + const currentProject = projects.find((project) => project.id === task.project_id); + setNewProjectName(currentProject ? currentProject.name : ''); + }, [task, projects]); + + useEffect(() => { + const loadTags = async () => { + setTagsLoading(true); + try { + if (availableTags.length === 0) { + const fetchedTags = await fetchTags(); + setAvailableTags(fetchedTags); + } + } catch (error) { + setTagsError(true); + console.error("Error fetching tags:", error); + } finally { + setTagsLoading(false); + } + }; + + loadTags(); + }, [availableTags.length, setAvailableTags, setTagsError, setTagsLoading]); const handleChange = ( - e: React.ChangeEvent< - HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement - > + e: React.ChangeEvent ) => { const { name, value } = e.target; - setFormData((prev) => ({ - ...prev, - [name]: value, - })); + setFormData((prev) => ({ ...prev, [name]: value })); }; const handleTagsChange = useCallback((newTags: string[]) => { @@ -75,7 +89,9 @@ const TaskModal: React.FC = ({ setNewProjectName(query); setDropdownOpen(true); setFilteredProjects( - projects.filter((project) => project.name.toLowerCase().includes(query)) + projects.filter((project) => + project.name.toLowerCase().includes(query) + ) ); }; @@ -111,7 +127,7 @@ const TaskModal: React.FC = ({ }; const handleDeleteClick = () => { - setShowConfirmDialog(true); + setShowConfirmDialog(true); }; const handleDeleteConfirm = () => { @@ -137,10 +153,7 @@ const TaskModal: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if ( - modalRef.current && - !modalRef.current.contains(event.target as Node) - ) { + if (modalRef.current && !modalRef.current.contains(event.target as Node)) { handleClose(); } }; @@ -168,26 +181,6 @@ const TaskModal: React.FC = ({ if (!isOpen) return null; - if (isLoading) { - return ( - - - Loading tags... - - - ); - } - - if (isError) { - return ( - - - Error loading tags. - - - ); - } - return ( <> = ({ className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-3xl overflow-hidden transform transition-transform duration-300 ${ isClosing ? "scale-95" : "scale-100" } h-screen sm:h-auto flex flex-col`} - style={{ - maxHeight: "calc(100vh - 4rem)", - }} + style={{ maxHeight: "calc(100vh - 4rem)" }} > - {/* Task Name */} = ({ placeholder="Add Task Name" /> - - {/* Tags */} Tags @@ -234,8 +222,6 @@ const TaskModal: React.FC = ({ /> - - {/* Project */} Project @@ -280,8 +266,6 @@ const TaskModal: React.FC = ({ )} - - {/* Status and Priority */} @@ -319,8 +303,6 @@ const TaskModal: React.FC = ({ /> - - {/* Note */} Note @@ -336,8 +318,6 @@ const TaskModal: React.FC = ({ > - - {/* Task Actions */} = ({ - {showConfirmDialog && ( = ({ ); }; -export default TaskModal; +export default TaskModal; \ No newline at end of file diff --git a/app/frontend/components/Task/TasksToday.tsx b/app/frontend/components/Task/TasksToday.tsx index 7aa6bd5..c7c78b7 100644 --- a/app/frontend/components/Task/TasksToday.tsx +++ b/app/frontend/components/Task/TasksToday.tsx @@ -1,81 +1,101 @@ -import React from "react"; +import React, { useEffect } from "react"; import { format } from "date-fns"; import { ClipboardDocumentListIcon, - ClockIcon, ArrowPathIcon, - CalendarDaysIcon, + CalendarDaysIcon, + ClockIcon, } from "@heroicons/react/24/outline"; - +import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService"; +import { fetchProjects } from "../../utils/projectsService"; import { Task } from "../../entities/Task"; -import { Project } from "../../entities/Project"; - -import useFetchTasks from "../../hooks/useFetchTasks"; -import useFetchProjects from "../../hooks/useFetchProjects"; -import useManageTasks from "../../hooks/useManageTasks"; - -import NewTask from "./NewTask"; +import { useStore } from "../../store/useStore"; import TaskList from "./TaskList"; +import { Metrics } from "../../entities/Metrics"; const TasksToday: React.FC = () => { const { tasks, - metrics, - isLoading: loadingTasks, - isError: errorTasks, - mutate: mutateTasks, - } = useFetchTasks({ - type: "today", - }); - + setTasks, + setLoading: setTasksLoading, + setError: setTasksError, + } = useStore((state) => state.tasksStore); const { projects, - isLoading: loadingProjects, - isError: errorProjects, - } = useFetchProjects(); + setProjects, + setLoading: setProjectsLoading, + setError: setProjectsError, + } = useStore((state) => state.projectsStore); - const { updateTask, deleteTask } = useManageTasks(); + const [metrics, setMetrics] = React.useState({ + total_open_tasks: 0, + tasks_pending_over_month: 0, + tasks_in_progress_count: 0, + tasks_in_progress: [], + tasks_due_today: [], + suggested_tasks: [], + }); - const handleTaskUpdate = (updatedTask: Task): void => { - if (updatedTask.id === undefined) { - console.error("Error updating task: Task ID is undefined."); - return; + useEffect(() => { + const loadData = async () => { + try { + // setProjectsLoading(true); + const projectsData = await fetchProjects(); + setProjects(projectsData); + + const { tasks: fetchedTasks, metrics } = await fetchTasks("?type=today"); + setTasks(fetchedTasks); + setMetrics(metrics); + } catch (error) { + console.error("Error loading data:", error); + setProjectsError(true); + setTasksError(true); + } finally { + // setProjectsLoading(false); + // setTasksLoading(false); + } + }; + + loadData(); + }, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]); + + const handleTaskUpdate = async (updatedTask: Task): Promise => { + if (!updatedTask.id) return; + + try { + setTasksLoading(true); + await updateTask(updatedTask.id, updatedTask); + const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); + setTasks(updatedTasks); + setMetrics(metrics); + } catch (error) { + console.error("Error updating task:", error); + setTasksError(true); + } finally { + setTasksLoading(false); } - updateTask(updatedTask.id, updatedTask) - .then(() => { - mutateTasks(); - }) - .catch((error) => { - console.error("Error updating task:", error); - }); }; - const handleTaskDelete = (taskId: number): void => { - deleteTask(taskId) - .then(() => { - mutateTasks(); - }) - .catch((error) => { - console.error("Error deleting task:", error); - }); + const handleTaskDelete = async (taskId: number): Promise => { + try { + setTasksLoading(true); + await deleteTask(taskId); + const { tasks: updatedTasks, metrics } = await fetchTasks("?type=today"); + setTasks(updatedTasks); + setMetrics(metrics); + } catch (error) { + console.error("Error deleting task:", error); + setTasksError(true); + } finally { + setTasksLoading(false); + } }; - if (loadingTasks || loadingProjects) { - return Loading...; - } - - if (errorTasks) { - return Error loading tasks.; - } - - if (errorProjects) { - return Error loading projects.; - } + const todayDate = format(new Date(), "yyyy-MM-dd"); return ( - {/* Header */} Today @@ -85,29 +105,17 @@ const TasksToday: React.FC = () => { - {/* Overview of Tasks */} - {/* Total Open Tasks */} Backlog - {metrics.total_open_tasks} - - - - {/* Tasks Pending Over a Month */} - - - - Stale - {metrics.tasks_pending_over_month} + {metrics.total_open_tasks} - {/* Tasks In Progress */} @@ -118,7 +126,6 @@ const TasksToday: React.FC = () => { - {/* Tasks Due Today */} @@ -128,12 +135,21 @@ const TasksToday: React.FC = () => { + + + + + Stale + + {metrics.tasks_pending_over_month} + + + - {/* Tasks Due Today */} {metrics.tasks_due_today.length > 0 && ( <> - Tasks Due Today + Due Today { > )} - {/* Tasks In Progress */} {metrics.tasks_in_progress.length > 0 && ( <> - Tasks In Progress + In Progress { > )} - {/* Suggested Tasks */} {metrics.suggested_tasks.length > 0 && ( <> - Suggested Tasks + Suggested { > )} - {/* Fallback Message */} {tasks.length === 0 && ( No tasks available for today. @@ -180,4 +193,4 @@ const TasksToday: React.FC = () => { ); }; -export default TasksToday; +export default TasksToday; \ No newline at end of file diff --git a/app/frontend/components/Tasks.tsx b/app/frontend/components/Tasks.tsx index 508f334..4ea86a5 100644 --- a/app/frontend/components/Tasks.tsx +++ b/app/frontend/components/Tasks.tsx @@ -11,6 +11,7 @@ import { XMarkIcon, ChevronDownIcon, ChevronDoubleDownIcon, + MagnifyingGlassIcon, } from "@heroicons/react/24/solid"; const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); @@ -22,8 +23,9 @@ const Tasks: React.FC = () => { const [error, setError] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [orderBy, setOrderBy] = useState("due_date:asc"); - const dropdownRef = useRef(null); + const [taskSearchQuery, setTaskSearchQuery] = useState(""); + const dropdownRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); const query = new URLSearchParams(location.search); @@ -198,6 +200,10 @@ const Tasks: React.FC = () => { return status !== "done"; }; + const filteredTasks = tasks.filter((task) => + task.name.toLowerCase().includes(taskSearchQuery.toLowerCase()) + ); + return ( @@ -268,10 +274,25 @@ const Tasks: React.FC = () => { + {/* Description */} {description} + + {/* Search Bar */} + + + + setTaskSearchQuery(e.target.value)} + className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" + /> + + {loading ? ( Loading... ) : error ? ( @@ -288,9 +309,9 @@ const Tasks: React.FC = () => { )} {/* Task List */} - {tasks.length > 0 ? ( + {filteredTasks.length > 0 ? ( { ); }; -export default Tasks; +export default Tasks; \ No newline at end of file diff --git a/app/frontend/contexts/DataContext.tsx b/app/frontend/contexts/DataContext.tsx deleted file mode 100644 index 8373498..0000000 --- a/app/frontend/contexts/DataContext.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { createContext, useContext } from 'react'; -import useFetchTags from '../hooks/useFetchTags'; -import useFetchAreas from '../hooks/useFetchAreas'; -import useFetchProjects from '../hooks/useFetchProjects'; -import useManageAreas from '../hooks/useManageAreas'; -import useManageNotes from '../hooks/useManageNotes'; -import useManageProjects from '../hooks/useManageProjects'; -import useManageTags from '../hooks/useManageTags'; -import useManageTasks from '../hooks/useManageTasks'; -import { Project } from '../entities/Project'; - -interface DataContextProps { - tasks: any[]; - tags: any[]; - areas: any[]; - notes: any[]; - projects: Project[]; - isLoading: boolean; - isError: boolean; - createNote: (noteData: any) => Promise; - updateNote: (noteId: number, noteData: any) => Promise; - deleteNote: (noteId: number) => Promise; - createArea: (areaData: any) => Promise; - updateArea: (areaId: number, areaData: any) => Promise; - deleteArea: (areaId: number) => Promise; - createProject: (projectData: any) => Promise; - updateProject: (projectId: number, projectData: any) => Promise; - deleteProject: (projectId: number) => Promise; - createTag: (tagData: any) => Promise; - updateTag: (tagId: number, tagData: any) => Promise; - deleteTag: (tagId: number) => Promise; - createTask: (taskData: any) => Promise; - updateTask: (taskId: number, taskData: any) => Promise; - deleteTask: (taskId: number) => Promise; - mutateTags: () => void; - mutateAreas: () => void; - mutateNotes: () => void; - mutateProjects: () => void; -} - -const DataContext = createContext(undefined); - -export const useDataContext = () => { - const context = useContext(DataContext); - if (!context) { - throw new Error('useDataContext must be used within a DataProvider'); - } - return context; -}; - -export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { tags, isLoading: isLoadingTags, isError: isErrorTags, mutate: mutateTags } = useFetchTags(); - const { areas, isLoading: isLoadingAreas, isError: isErrorAreas, mutate: mutateAreas } = useFetchAreas(); - const { - projects, - isLoading: isLoadingProjects, - isError: isErrorProjects, - mutate: mutateProjects, - } = useFetchProjects(); - const { createArea, updateArea, deleteArea } = useManageAreas(); - const { createProject, updateProject, deleteProject } = useManageProjects(); - const { createTag, updateTag, deleteTag } = useManageTags(); - const { - tasks, - isLoading: isLoadingTasks, - isError: isErrorTasks, - createTask, - updateTask, - deleteTask, - } = useManageTasks(); - const { - notes, - isLoading: isLoadingNotes, - isError: isErrorNotes, - createNote, - updateNote, - deleteNote, - mutate: mutateNotes, - } = useManageNotes(); - - const isLoading = isLoadingTags || isLoadingAreas || isLoadingNotes || isLoadingTasks || isLoadingProjects; - const isError = isErrorTags || isErrorAreas || isErrorNotes || isErrorTasks || isErrorProjects; - - return ( - - {children} - - ); -}; diff --git a/app/frontend/entities/Metrics.ts b/app/frontend/entities/Metrics.ts new file mode 100644 index 0000000..56e512e --- /dev/null +++ b/app/frontend/entities/Metrics.ts @@ -0,0 +1,10 @@ +import { Task } from "./Task"; + +export interface Metrics { + total_open_tasks: number; + tasks_pending_over_month: number; + tasks_in_progress_count: number; + tasks_in_progress: Task[]; + tasks_due_today: Task[]; + suggested_tasks: Task[]; +} \ No newline at end of file diff --git a/app/frontend/entities/Project.ts b/app/frontend/entities/Project.ts index 27876c3..b22e34f 100644 --- a/app/frontend/entities/Project.ts +++ b/app/frontend/entities/Project.ts @@ -1,6 +1,6 @@ import { Area } from "./Area"; import { Tag } from "./Tag"; -import { PriorityType } from "./Task"; +import { PriorityType, Task } from "./Task"; export interface Project { id?: number; @@ -12,4 +12,6 @@ export interface Project { area_id?: number | null; tags?: Tag[]; priority?: PriorityType; + tasks?: Task[]; + due_date_at?: string; } \ No newline at end of file diff --git a/app/frontend/hooks/useFetch.ts b/app/frontend/hooks/useFetch.ts deleted file mode 100644 index 63607ab..0000000 --- a/app/frontend/hooks/useFetch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useState, useEffect } from 'react'; - -interface UseFetchResult { - data: T | null; - loading: boolean; - error: string | null; -} - -const useFetch = (url: string, options?: RequestInit): UseFetchResult => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let isMounted = true; - const controller = new AbortController(); - - const fetchData = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch(url, { ...options, signal: controller.signal }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch data.'); - } - const result: T = await response.json(); - if (isMounted) { - setData(result); - } - } catch (err: any) { - if (isMounted) { - if (err.name !== 'AbortError') { - setError(err.message); - } - } - } finally { - if (isMounted) setLoading(false); - } - }; - - fetchData(); - - return () => { - isMounted = false; - controller.abort(); - }; - }, [url, JSON.stringify(options)]); - - return { data, loading, error }; -}; - -export default useFetch; diff --git a/app/frontend/hooks/useFetchAreas.ts b/app/frontend/hooks/useFetchAreas.ts deleted file mode 100644 index 68557cf..0000000 --- a/app/frontend/hooks/useFetchAreas.ts +++ /dev/null @@ -1,16 +0,0 @@ -import useSWR from 'swr'; -import { Area } from '../entities/Area'; -import { fetcher } from '../utils/fetcher'; - -const useFetchAreas = () => { - const { data, error, mutate } = useSWR('/api/areas?active=true', fetcher); - - return { - areas: data || [], - isLoading: !error && !data, - isError: !!error, - mutate, - }; -}; - -export default useFetchAreas; diff --git a/app/frontend/hooks/useFetchNotes.ts b/app/frontend/hooks/useFetchNotes.ts deleted file mode 100644 index a75e5ae..0000000 --- a/app/frontend/hooks/useFetchNotes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useFetch from './useFetch'; -import { Note } from '../entities/Note'; - -const useFetchNotes = () => { - const { data, loading, error } = useFetch('/api/notes', { - credentials: 'include', - headers: { - Accept: 'application/json', - }, - }); - - return { notes: data || [], loading, error }; -}; - -export default useFetchNotes; diff --git a/app/frontend/hooks/useFetchProjects.ts b/app/frontend/hooks/useFetchProjects.ts deleted file mode 100644 index fbf6eef..0000000 --- a/app/frontend/hooks/useFetchProjects.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Project } from '../entities/Project'; - -interface UseFetchProjectsOptions { - activeFilter?: string; - areaFilter?: string; -} - -interface UseFetchProjectsResult { - projects: Project[]; - taskStatusCounts?: any; - isLoading: boolean; - isError: boolean; - mutate: () => void; -} - -const useFetchProjects = (options?: UseFetchProjectsOptions): UseFetchProjectsResult => { - const [projects, setProjects] = useState([]); - const [taskStatusCounts, setTaskStatusCounts] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - const fetchProjects = async () => { - setIsLoading(true); - setIsError(false); - try { - let url = '/api/projects'; - const params = new URLSearchParams(); - - if (options?.activeFilter !== undefined && options.activeFilter !== "all") { - params.append('active', String(options.activeFilter)); - } - if (options?.areaFilter !== undefined) { - params.append('area_id', options.areaFilter); - } - - if (params.toString()) { - url += `?${params.toString()}`; - } - - const response = await fetch(url, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }); - - if (response.ok) { - const data = await response.json(); - - if (data.projects) { - setProjects(data.projects); - setTaskStatusCounts(data.taskStatusCounts); - } else { - setProjects(data); - setTaskStatusCounts(undefined); - } - } else { - throw new Error('Failed to fetch projects.'); - } - } catch (error) { - console.error('Error fetching projects:', error); - setIsError(true); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchProjects(); - }, [options?.activeFilter, options?.areaFilter]); - - return { projects, taskStatusCounts, isLoading, isError, mutate: fetchProjects }; -}; - -export default useFetchProjects; diff --git a/app/frontend/hooks/useFetchTags.ts b/app/frontend/hooks/useFetchTags.ts deleted file mode 100644 index 4334650..0000000 --- a/app/frontend/hooks/useFetchTags.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useSWR from 'swr'; -import { fetcher } from '../utils/fetcher'; - -const useFetchTags = () => { - const { data, error, mutate } = useSWR('/api/tags', fetcher); - - return { - tags: data || [], - isLoading: !data && !error, - isError: !!error, - mutate, - }; -}; - -export default useFetchTags; diff --git a/app/frontend/hooks/useFetchTasks.ts b/app/frontend/hooks/useFetchTasks.ts deleted file mode 100644 index 2d139d1..0000000 --- a/app/frontend/hooks/useFetchTasks.ts +++ /dev/null @@ -1,69 +0,0 @@ -import useSWR from 'swr'; -import { Task } from '../entities/Task'; - -interface UseFetchTasksOptions { - type?: string; - tag?: string; -} - -interface Metrics { - total_open_tasks: number; - tasks_pending_over_month: number; - tasks_in_progress_count: number; - tasks_in_progress: Task[]; - tasks_due_today: Task[]; - suggested_tasks: Task[]; -} - -interface UseFetchTasksResult { - tasks: Task[]; - metrics: Metrics; - isLoading: boolean; - isError: boolean; - mutate: () => void; -} - -const initialMetrics: Metrics = { - total_open_tasks: 0, - tasks_pending_over_month: 0, - tasks_in_progress_count: 0, - tasks_in_progress: [], - tasks_due_today: [], - suggested_tasks: [], -}; - -const fetcher = (url: string) => - fetch(url, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }).then((res) => { - if (!res.ok) { - throw new Error('Failed to fetch tasks.'); - } - return res.json(); - }); - -const useFetchTasks = (options?: UseFetchTasksOptions): UseFetchTasksResult => { - const params = new URLSearchParams(); - - if (options?.type) { - params.append('type', options.type); - } - if (options?.tag) { - params.append('tag', options.tag); - } - - const queryString = params.toString(); - const url = `/api/tasks${queryString ? `?${queryString}` : ''}`; - const { data, error, mutate } = useSWR(url, fetcher); - - return { - tasks: data?.tasks || [], - metrics: data?.metrics || initialMetrics, - isLoading: !error && !data, - isError: !!error, - mutate, - }; -}; - -export default useFetchTasks; diff --git a/app/frontend/hooks/useManageAreas.ts b/app/frontend/hooks/useManageAreas.ts deleted file mode 100644 index 590ab7d..0000000 --- a/app/frontend/hooks/useManageAreas.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback } from 'react'; -import { useSWRConfig } from 'swr'; -import { Area } from '../entities/Area'; - -const useManageAreas = () => { - const { mutate } = useSWRConfig(); - - const createArea = useCallback(async (areaData: Partial) => { - try { - const response = await fetch('/api/areas', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(areaData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create area.'); - } - const newArea: Area = await response.json(); - mutate('/api/areas?active=true', (current: Area[] = []) => [...current, newArea], false); - } catch (error) { - console.error('Error creating area:', error); - throw error; - } - }, [mutate]); - - const updateArea = useCallback(async (areaId: number, areaData: Partial) => { - try { - const response = await fetch(`/api/areas/${areaId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(areaData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update area.'); - } - const updatedArea: Area = await response.json(); - mutate('/api/areas?active=true', (current: Area[] = []) => - current.map(area => (area.id === areaId ? updatedArea : area)), - false - ); - } catch (error) { - console.error('Error updating area:', error); - throw error; - } - }, [mutate]); - - const deleteArea = useCallback(async (areaId: number) => { - try { - const response = await fetch(`/api/areas/${areaId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete area.'); - } - mutate('/api/areas?active=true', (current: Area[] = []) => - current.filter(area => area.id !== areaId), - false - ); - } catch (error) { - console.error('Error deleting area:', error); - throw error; - } - }, [mutate]); - - return { createArea, updateArea, deleteArea }; -}; - -export default useManageAreas; diff --git a/app/frontend/hooks/useManageNotes.ts b/app/frontend/hooks/useManageNotes.ts deleted file mode 100644 index ce3e768..0000000 --- a/app/frontend/hooks/useManageNotes.ts +++ /dev/null @@ -1,92 +0,0 @@ -import useSWR from 'swr'; -import { Note } from '../entities/Note'; -import { fetcher } from '../utils/fetcher'; -import { useCallback } from 'react'; - -const useManageNotes = () => { - const { data, error, mutate } = useSWR('/api/notes', fetcher); - - const createNote = useCallback( - async (noteData: Partial) => { - const noteDataToSend = { - ...noteData, - tags: noteData.tags?.map((tag) => tag.name) || [], - }; - const response = await fetch('/api/note', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(noteDataToSend), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create note.'); - } - - const newNote: Note = await response.json(); - mutate([...(data || []), newNote], false); - }, - [mutate, data] - ); - - const updateNote = useCallback( - async (noteId: number, noteData: Partial) => { - const noteDataToSend = { - ...noteData, - tags: noteData.tags?.map((tag) => tag.name) || [], - }; - const response = await fetch(`/api/note/${noteId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(noteDataToSend), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update note.'); - } - - const updatedNote: Note = await response.json(); - mutate((data || []).map((note) => (note.id === noteId ? updatedNote : note)), false); - }, - [mutate, data] - ); - - const deleteNote = useCallback( - async (noteId: number) => { - const response = await fetch(`/api/note/${noteId}`, { - method: 'DELETE', - credentials: 'include', - headers: { - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete note.'); - } - - mutate((data || []).filter((note) => note.id !== noteId), false); - }, - [mutate, data] - ); - - return { - notes: data || [], - isLoading: !error && !data, - isError: error, - createNote, - updateNote, - deleteNote, - mutate - }; -}; - -export default useManageNotes; diff --git a/app/frontend/hooks/useManageProjects.ts b/app/frontend/hooks/useManageProjects.ts deleted file mode 100644 index a0e91d1..0000000 --- a/app/frontend/hooks/useManageProjects.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useSWRConfig } from 'swr'; -import { Project } from '../entities/Project'; - -const useManageProjects = () => { - const { mutate } = useSWRConfig(); - const [projects, setProjects] = useState([]); - - const createProject = async (projectData: Partial): Promise => { - try { - const response = await fetch('/api/project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(projectData), - }); - if (response.ok) { - const newProject = await response.json(); - setProjects((prevProjects) => [...prevProjects, newProject]); - return newProject; - } else { - throw new Error('Failed to create project.'); - } - } catch (error) { - console.error('Error creating project:', error); - throw error; - } - }; - - const updateProject = useCallback(async (projectId: number, projectData: Partial) => { - try { - const response = await fetch(`/api/project/${projectId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(projectData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update project.'); - } - const updatedProject: Project = await response.json(); - mutate('/api/projects', (current: Project[] = []) => - current.map((project) => (project.id === projectId ? updatedProject : project)), false); - return updatedProject; - } catch (error) { - console.error('Error updating project:', error); - throw error; - } - }, [mutate]); - - const deleteProject = useCallback(async (projectId: number) => { - try { - const response = await fetch(`/api/project/${projectId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete project.'); - } - mutate('/api/projects', (current: Project[] = []) => - current.filter((project) => project.id !== projectId), false); - } catch (error) { - console.error('Error deleting project:', error); - throw error; - } - }, [mutate]); - - return { projects, createProject, updateProject, deleteProject }; -}; - -export default useManageProjects; diff --git a/app/frontend/hooks/useManageTags.ts b/app/frontend/hooks/useManageTags.ts deleted file mode 100644 index 09eef32..0000000 --- a/app/frontend/hooks/useManageTags.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback } from 'react'; -import { useSWRConfig } from 'swr'; -import { Tag } from '../entities/Tag'; - -const useManageTags = () => { - const { mutate } = useSWRConfig(); - - const createTag = useCallback(async (tagData: Partial) => { - try { - const response = await fetch('/api/tag', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tagData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create tag.'); - } - const newTag: Tag = await response.json(); - mutate('/api/tags', (current: Tag[] = []) => [...current, newTag], false); - } catch (error) { - console.error('Error creating tag:', error); - throw error; - } - }, [mutate]); - - const updateTag = useCallback(async (tagId: number, tagData: Partial) => { - try { - const response = await fetch(`/api/tag/${tagId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tagData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update tag.'); - } - const updatedTag: Tag = await response.json(); - mutate('/api/tags', (current: Tag[] = []) => - current.map(tag => (tag.id === tagId ? updatedTag : tag)), false - ); - } catch (error) { - console.error('Error updating tag:', error); - throw error; - } - }, [mutate]); - - const deleteTag = useCallback(async (tagId: number) => { - try { - const response = await fetch(`/api/tag/${tagId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete tag.'); - } - mutate('/api/tags', (current: Tag[] = []) => - current.filter(tag => tag.id !== tagId), false - ); - } catch (error) { - console.error('Error deleting tag:', error); - throw error; - } - }, [mutate]); - - return { createTag, updateTag, deleteTag }; -}; - -export default useManageTags; diff --git a/app/frontend/hooks/useManageTasks.ts b/app/frontend/hooks/useManageTasks.ts deleted file mode 100644 index 9f6c5f1..0000000 --- a/app/frontend/hooks/useManageTasks.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from 'react'; -import { Task } from '../entities/Task'; - -const useManageTasks = () => { - const [tasks, setTasks] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - - const fetchTasks = async (query: string = '') => { - setIsLoading(true); - setIsError(false); - try { - const response = await fetch(`/api/tasks${query}`, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = await response.json(); - setTasks(data); - } else { - throw new Error('Failed to fetch tasks.'); - } - } catch (error) { - setIsError(true); - } finally { - setIsLoading(false); - } - }; - - const createTask = async (taskData: Partial) => { - try { - const response = await fetch('/api/task', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(taskData), - }); - if (response.ok) { - const newTask = await response.json(); - setTasks((prevTasks) => [newTask, ...prevTasks]); - } else { - throw new Error('Failed to create task.'); - } - } catch (error) { - console.error('Error creating task:', error); - } - }; - - const updateTask = async (taskId: number, taskData: Partial) => { - try { - const response = await fetch(`/api/task/${taskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(taskData), - }); - if (response.ok) { - const updatedTask = await response.json(); - setTasks((prevTasks) => - prevTasks.map((task) => (task.id === taskId ? updatedTask : task)) - ); - } else { - throw new Error('Failed to update task.'); - } - } catch (error) { - console.error('Error updating task:', error); - } - }; - - const deleteTask = async (taskId: number) => { - try { - const response = await fetch(`/api/task/${taskId}`, { - method: 'DELETE', - credentials: 'include', - }); - if (response.ok) { - setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); - } else { - throw new Error('Failed to delete task.'); - } - } catch (error) { - console.error('Error deleting task:', error); - } - }; - - const mutateTasks = fetchTasks; - - return { tasks, isLoading, isError, fetchTasks, mutateTasks, createTask, updateTask, deleteTask }; -}; - -export default useManageTasks; diff --git a/app/frontend/store/useStore.ts b/app/frontend/store/useStore.ts new file mode 100644 index 0000000..029d47f --- /dev/null +++ b/app/frontend/store/useStore.ts @@ -0,0 +1,102 @@ +import { create } from "zustand"; +import { Project } from "../entities/Project"; +import { Area } from "../entities/Area"; +import { Note } from "../entities/Note"; +import { Task } from "../entities/Task"; +import { Tag } from "../entities/Tag"; + +interface NotesStore { + notes: Note[]; + isLoading: boolean; + isError: boolean; + setNotes: (notes: Note[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface AreasStore { + areas: Area[]; + isLoading: boolean; + isError: boolean; + setAreas: (areas: Area[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface ProjectsStore { + projects: Project[]; + isLoading: boolean; + isError: boolean; + setProjects: (projects: Project[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface TagsStore { + tags: Tag[]; + isLoading: boolean; + isError: boolean; + setTags: (tags: Tag[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface TasksStore { + tasks: Task[]; + isLoading: boolean; + isError: boolean; + setTasks: (tasks: Task[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface StoreState { + notesStore: NotesStore; + areasStore: AreasStore; + projectsStore: ProjectsStore; + tagsStore: TagsStore; + tasksStore: TasksStore; +} + +export const useStore = create((set) => ({ + notesStore: { + notes: [], + isLoading: false, + isError: false, + setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })), + setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })), + setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })), + }, + areasStore: { + areas: [], + isLoading: false, + isError: false, + setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })), + setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })), + setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })), + }, + projectsStore: { + projects: [], + isLoading: false, + isError: false, + setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })), + setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })), + setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })), + }, + tagsStore: { + tags: [], + isLoading: false, + isError: false, + setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })), + setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })), + setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })), + }, + tasksStore: { + tasks: [], + isLoading: false, + isError: false, + setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })), + setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })), + setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })), + }, +})); \ No newline at end of file diff --git a/app/frontend/utils/areasService.ts b/app/frontend/utils/areasService.ts new file mode 100644 index 0000000..be60bf0 --- /dev/null +++ b/app/frontend/utils/areasService.ts @@ -0,0 +1,40 @@ +import { Area } from "../entities/Area"; + +export const fetchAreas = async (): Promise => { + const response = await fetch("/api/areas?active=true"); + if (!response.ok) throw new Error('Failed to fetch areas.'); + + return await response.json(); +}; + +export const createArea = async (areaData: Partial): Promise => { + const response = await fetch('/api/areas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(areaData), + }); + + if (!response.ok) throw new Error('Failed to create area.'); + + return await response.json(); +}; + +export const updateArea = async (areaId: number, areaData: Partial): Promise => { + const response = await fetch(`/api/areas/${areaId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(areaData), + }); + + if (!response.ok) throw new Error('Failed to update area.'); + + return await response.json(); +}; + +export const deleteArea = async (areaId: number): Promise => { + const response = await fetch(`/api/areas/${areaId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete area.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/notesService.ts b/app/frontend/utils/notesService.ts new file mode 100644 index 0000000..060af6b --- /dev/null +++ b/app/frontend/utils/notesService.ts @@ -0,0 +1,40 @@ +import { Note } from "../entities/Note"; + +export const fetchNotes = async (): Promise => { + const response = await fetch("/api/notes"); + if (!response.ok) throw new Error('Failed to fetch notes.'); + + return await response.json(); +}; + +export const createNote = async (noteData: Note): Promise => { + const response = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(noteData), + }); + + if (!response.ok) throw new Error('Failed to create note.'); + + return await response.json(); +}; + +export const updateNote = async (noteId: number, noteData: Note): Promise => { + const response = await fetch(`/api/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(noteData), + }); + + if (!response.ok) throw new Error('Failed to update note.'); + + return await response.json(); +}; + +export const deleteNote = async (noteId: number): Promise => { + const response = await fetch(`/api/notes/${noteId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete note.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/projectsService.ts b/app/frontend/utils/projectsService.ts new file mode 100644 index 0000000..2f18a35 --- /dev/null +++ b/app/frontend/utils/projectsService.ts @@ -0,0 +1,63 @@ +import { Project } from "../entities/Project"; + +export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise => { + let url = `/api/projects`; + const params = new URLSearchParams(); + + if (activeFilter !== "all") params.append("active", activeFilter); + if (areaFilter) params.append("area_id", areaFilter); + if (params.toString()) url += `?${params.toString()}`; + + const response = await fetch(url, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) throw new Error('Failed to fetch projects.'); + + const data = await response.json(); + return data.projects || data; +}; + +export const fetchProjectById = async (projectId: string): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) throw new Error('Failed to fetch project details.'); + + return await response.json(); +}; + +export const createProject = async (projectData: Partial): Promise => { + const response = await fetch('/api/project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) throw new Error('Failed to create project.'); + + return await response.json(); +}; + +export const updateProject = async (projectId: number, projectData: Partial): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) throw new Error('Failed to update project.'); + + return await response.json(); +}; + +export const deleteProject = async (projectId: number): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete project.'); +}; diff --git a/app/frontend/utils/tagsService.ts b/app/frontend/utils/tagsService.ts new file mode 100644 index 0000000..ba535b8 --- /dev/null +++ b/app/frontend/utils/tagsService.ts @@ -0,0 +1,40 @@ +import { Tag } from "../entities/Tag"; + +export const fetchTags = async (): Promise => { + const response = await fetch("/api/tags"); + if (!response.ok) throw new Error('Failed to fetch tags.'); + + return await response.json(); +}; + +export const createTag = async (tagData: Tag): Promise => { + const response = await fetch('/api/tag', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tagData), + }); + + if (!response.ok) throw new Error('Failed to create tag.'); + + return await response.json(); +}; + +export const updateTag = async (tagId: number, tagData: Tag): Promise => { + const response = await fetch(`/api/tag/${tagId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tagData), + }); + + if (!response.ok) throw new Error('Failed to update tag.'); + + return await response.json(); +}; + +export const deleteTag = async (tagId: number): Promise => { + const response = await fetch(`/api/tag/${tagId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete tag.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/tasksService.ts b/app/frontend/utils/tasksService.ts new file mode 100644 index 0000000..467df67 --- /dev/null +++ b/app/frontend/utils/tasksService.ts @@ -0,0 +1,52 @@ +import { Metrics } from "../entities/Metrics"; +import { Task } from "../entities/Task"; + +export const fetchTasks = async (query = ''): Promise<{ tasks: Task[]; metrics: Metrics }> => { + const response = await fetch(`/api/tasks${query}`); + + if (!response.ok) throw new Error('Failed to fetch tasks.'); + + const result = await response.json(); + + if (!Array.isArray(result.tasks)) { + throw new Error('Resulting tasks are not an array.'); + } + + if (!result.metrics) { + throw new Error('Metrics data is not included.'); + } + + return { tasks: result.tasks, metrics: result.metrics }; +}; + +export const createTask = async (taskData: Task): Promise => { + const response = await fetch('/api/task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(taskData), + }); + + if (!response.ok) throw new Error('Failed to create task.'); + + return await response.json(); +}; + +export const updateTask = async (taskId: number, taskData: Task): Promise => { + const response = await fetch(`/api/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(taskData), + }); + + if (!response.ok) throw new Error('Failed to update task.'); + + return await response.json(); +}; + +export const deleteTask = async (taskId: number): Promise => { + const response = await fetch(`/api/task/${taskId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete task.'); +}; diff --git a/app/models/project.rb b/app/models/project.rb index 6059ef0..db73f0f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -32,4 +32,8 @@ class Project < ActiveRecord::Base completed_tasks = counts[:total] - counts[:not_started] (completed_tasks.to_f / counts[:total] * 100).round end + + def due_date_at + self[:due_date_at]&.strftime('%Y-%m-%d') + end end diff --git a/app/models/task.rb b/app/models/task.rb index a617036..9d86ffd 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -76,47 +76,103 @@ class Task < ActiveRecord::Base def self.compute_metrics(user) total_open_tasks = user.tasks.incomplete.count - one_month_ago = Date.today - 30 tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count - tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]) + tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]).order(priority: :desc) tasks_in_progress_count = tasks_in_progress.count - tasks_due_today = user.tasks.due_today + # Calculate tasks due today including those due via projects + tasks_due_today = user.tasks.incomplete.joins(:project) + .where('tasks.due_date <= ? OR projects.due_date_at <= ?', Date.today, Date.today) + .distinct - # Exclude tasks that are in progress or due today + # Gather an array of IDs to be excluded from suggested tasks excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id) - # Fetch suggested tasks not in projects, ordered by task priority + # Gather tasks in projects expiring starting today, order by task priority + tasks_in_expiring_projects = user.tasks.incomplete + .joins(:project) + .where('projects.due_date_at >= ?', Date.today) + .where(projects: { active: true }) # Only active projects + .where.not(id: excluded_task_ids) + .order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC')) + .limit(5) + + # Gather tasks not assigned to projects expiring today, ordered by task priority tasks_without_projects = user.tasks.incomplete - .where(status: statuses[:not_started], project_id: nil) - .where.not(id: excluded_task_ids) - .order(priority: :desc) - .limit(3) + .where(status: statuses[:not_started], project_id: nil) + .or(user.tasks.where(project_id: nil, status: statuses[:not_started])) + .where.not(id: excluded_task_ids) + .order(priority: :desc) + .limit(5) - # Fetch suggested tasks in projects, ordered by task priority and project priority - tasks_in_projects = user.tasks.incomplete - .where(status: statuses[:not_started]) - .where.not(project_id: nil) - .where.not(id: excluded_task_ids) - .joins('LEFT JOIN projects ON tasks.project_id = projects.id') - .order( - Arel.sql('tasks.priority DESC, projects.priority DESC') - ) - .distinct - .limit(3) + # Combine both list of suggested tasks + suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects) { total_open_tasks: total_open_tasks, tasks_pending_over_month: tasks_pending_over_month, tasks_in_progress: tasks_in_progress, tasks_in_progress_count: tasks_in_progress_count, tasks_due_today: tasks_due_today, - suggested_tasks: (tasks_without_projects + tasks_in_projects) + suggested_tasks: suggested_tasks } end + def self.sort_suggested_tasks(tasks) + tasks.sort_by do |task| + # Parse or default the task due date + task_due_date = if task.due_date.is_a?(String) + Date.parse(task.due_date) + else + task.due_date || Date.new(9999, 12, 31) + end + + # Parse or default the project due date + project_due_date = if (task.project&.due_date_at).is_a?(String) + Date.parse(task&.project&.due_date_at) + else + task.project&.due_date_at || Date.new(9999, 12, 31) + end + + # Priority in descending order (sorted values should be negative for sort_by) + priority_value = -(Task.priorities.fetch(task.priority, -1)) + + # Determine sorting flags based on various criteria + is_high_priority_proj_with_due_date = (task.priority == 'high' && task&.project&.due_date_at) ? 0 : 1 + is_high_priority_with_due_date = (task.priority == 'high' && task.due_date) ? 0 : 1 + is_high_priority = (task.priority == 'high' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + is_medium_priority_proj_with_due_date = (task.priority == 'medium' && task&.project&.due_date_at) ? 0 : 1 + is_medium_priority_with_due_date = (task.priority == 'medium' && task.due_date) ? 0 : 1 + is_medium_priority = (task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + is_low_priority_proj_with_due_date = (task.priority == 'low' && task&.project&.due_date_at) ? 0 : 1 + is_low_priority_with_due_date = (task.priority == 'low' && task.due_date) ? 0 : 1 + is_low_priority = (task.priority == 'low' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + # Primary sorting criteria + [ + is_high_priority_proj_with_due_date, + is_high_priority_with_due_date, + is_high_priority, + + is_medium_priority_proj_with_due_date, + is_medium_priority_with_due_date, + is_medium_priority, + + is_low_priority_proj_with_due_date, + is_low_priority_with_due_date, + is_low_priority, + + task_due_date, + project_due_date, + priority_value + ] + end + end + def as_json(options = {}) super(options).merge( 'due_date' => due_date&.strftime('%Y-%m-%d') diff --git a/app/routes/projects_routes.rb b/app/routes/projects_routes.rb index 96cdbc4..3a53f76 100644 --- a/app/routes/projects_routes.rb +++ b/app/routes/projects_routes.rb @@ -75,7 +75,8 @@ class Sinatra::Application area_id: project_data['area_id'], active: true, pin_to_sidebar: false, - priority: project_data['priority'] + priority: project_data['priority'], + due_date_at: project_data['due_date_at'] ) if project.save @@ -106,7 +107,8 @@ class Sinatra::Application area_id: project_data['area_id'], active: project_data['active'], pin_to_sidebar: project_data['pin_to_sidebar'], - priority: project_data ['priority'] + priority: project_data ['priority'], + due_date_at: project_data['due_date_at'] ) if project.save diff --git a/app/views/index.erb b/app/views/index.erb index 89ff92b..3083eee 100644 --- a/app/views/index.erb +++ b/app/views/index.erb @@ -5,6 +5,6 @@ - +
Created on: {new Date(note.created_at || '').toLocaleDateString()}
Last updated: {new Date(note.updated_at || '').toLocaleDateString()}
{note.content}
+
+ {project.description}
Loading...
Error loading tasks.
Error loading projects.
Backlog
{metrics.total_open_tasks}
Stale
- {metrics.tasks_pending_over_month} + {metrics.total_open_tasks}
+ {metrics.tasks_pending_over_month} +
No tasks available for today. @@ -180,4 +193,4 @@ const TasksToday: React.FC = () => { ); }; -export default TasksToday; +export default TasksToday; \ No newline at end of file diff --git a/app/frontend/components/Tasks.tsx b/app/frontend/components/Tasks.tsx index 508f334..4ea86a5 100644 --- a/app/frontend/components/Tasks.tsx +++ b/app/frontend/components/Tasks.tsx @@ -11,6 +11,7 @@ import { XMarkIcon, ChevronDownIcon, ChevronDoubleDownIcon, + MagnifyingGlassIcon, } from "@heroicons/react/24/solid"; const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); @@ -22,8 +23,9 @@ const Tasks: React.FC = () => { const [error, setError] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [orderBy, setOrderBy] = useState("due_date:asc"); - const dropdownRef = useRef(null); + const [taskSearchQuery, setTaskSearchQuery] = useState(""); + const dropdownRef = useRef(null); const location = useLocation(); const navigate = useNavigate(); const query = new URLSearchParams(location.search); @@ -198,6 +200,10 @@ const Tasks: React.FC = () => { return status !== "done"; }; + const filteredTasks = tasks.filter((task) => + task.name.toLowerCase().includes(taskSearchQuery.toLowerCase()) + ); + return ( @@ -268,10 +274,25 @@ const Tasks: React.FC = () => { + {/* Description */} {description} + + {/* Search Bar */} + + + + setTaskSearchQuery(e.target.value)} + className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white" + /> + + {loading ? ( Loading... ) : error ? ( @@ -288,9 +309,9 @@ const Tasks: React.FC = () => { )} {/* Task List */} - {tasks.length > 0 ? ( + {filteredTasks.length > 0 ? ( { ); }; -export default Tasks; +export default Tasks; \ No newline at end of file diff --git a/app/frontend/contexts/DataContext.tsx b/app/frontend/contexts/DataContext.tsx deleted file mode 100644 index 8373498..0000000 --- a/app/frontend/contexts/DataContext.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { createContext, useContext } from 'react'; -import useFetchTags from '../hooks/useFetchTags'; -import useFetchAreas from '../hooks/useFetchAreas'; -import useFetchProjects from '../hooks/useFetchProjects'; -import useManageAreas from '../hooks/useManageAreas'; -import useManageNotes from '../hooks/useManageNotes'; -import useManageProjects from '../hooks/useManageProjects'; -import useManageTags from '../hooks/useManageTags'; -import useManageTasks from '../hooks/useManageTasks'; -import { Project } from '../entities/Project'; - -interface DataContextProps { - tasks: any[]; - tags: any[]; - areas: any[]; - notes: any[]; - projects: Project[]; - isLoading: boolean; - isError: boolean; - createNote: (noteData: any) => Promise; - updateNote: (noteId: number, noteData: any) => Promise; - deleteNote: (noteId: number) => Promise; - createArea: (areaData: any) => Promise; - updateArea: (areaId: number, areaData: any) => Promise; - deleteArea: (areaId: number) => Promise; - createProject: (projectData: any) => Promise; - updateProject: (projectId: number, projectData: any) => Promise; - deleteProject: (projectId: number) => Promise; - createTag: (tagData: any) => Promise; - updateTag: (tagId: number, tagData: any) => Promise; - deleteTag: (tagId: number) => Promise; - createTask: (taskData: any) => Promise; - updateTask: (taskId: number, taskData: any) => Promise; - deleteTask: (taskId: number) => Promise; - mutateTags: () => void; - mutateAreas: () => void; - mutateNotes: () => void; - mutateProjects: () => void; -} - -const DataContext = createContext(undefined); - -export const useDataContext = () => { - const context = useContext(DataContext); - if (!context) { - throw new Error('useDataContext must be used within a DataProvider'); - } - return context; -}; - -export const DataProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const { tags, isLoading: isLoadingTags, isError: isErrorTags, mutate: mutateTags } = useFetchTags(); - const { areas, isLoading: isLoadingAreas, isError: isErrorAreas, mutate: mutateAreas } = useFetchAreas(); - const { - projects, - isLoading: isLoadingProjects, - isError: isErrorProjects, - mutate: mutateProjects, - } = useFetchProjects(); - const { createArea, updateArea, deleteArea } = useManageAreas(); - const { createProject, updateProject, deleteProject } = useManageProjects(); - const { createTag, updateTag, deleteTag } = useManageTags(); - const { - tasks, - isLoading: isLoadingTasks, - isError: isErrorTasks, - createTask, - updateTask, - deleteTask, - } = useManageTasks(); - const { - notes, - isLoading: isLoadingNotes, - isError: isErrorNotes, - createNote, - updateNote, - deleteNote, - mutate: mutateNotes, - } = useManageNotes(); - - const isLoading = isLoadingTags || isLoadingAreas || isLoadingNotes || isLoadingTasks || isLoadingProjects; - const isError = isErrorTags || isErrorAreas || isErrorNotes || isErrorTasks || isErrorProjects; - - return ( - - {children} - - ); -}; diff --git a/app/frontend/entities/Metrics.ts b/app/frontend/entities/Metrics.ts new file mode 100644 index 0000000..56e512e --- /dev/null +++ b/app/frontend/entities/Metrics.ts @@ -0,0 +1,10 @@ +import { Task } from "./Task"; + +export interface Metrics { + total_open_tasks: number; + tasks_pending_over_month: number; + tasks_in_progress_count: number; + tasks_in_progress: Task[]; + tasks_due_today: Task[]; + suggested_tasks: Task[]; +} \ No newline at end of file diff --git a/app/frontend/entities/Project.ts b/app/frontend/entities/Project.ts index 27876c3..b22e34f 100644 --- a/app/frontend/entities/Project.ts +++ b/app/frontend/entities/Project.ts @@ -1,6 +1,6 @@ import { Area } from "./Area"; import { Tag } from "./Tag"; -import { PriorityType } from "./Task"; +import { PriorityType, Task } from "./Task"; export interface Project { id?: number; @@ -12,4 +12,6 @@ export interface Project { area_id?: number | null; tags?: Tag[]; priority?: PriorityType; + tasks?: Task[]; + due_date_at?: string; } \ No newline at end of file diff --git a/app/frontend/hooks/useFetch.ts b/app/frontend/hooks/useFetch.ts deleted file mode 100644 index 63607ab..0000000 --- a/app/frontend/hooks/useFetch.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { useState, useEffect } from 'react'; - -interface UseFetchResult { - data: T | null; - loading: boolean; - error: string | null; -} - -const useFetch = (url: string, options?: RequestInit): UseFetchResult => { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - let isMounted = true; - const controller = new AbortController(); - - const fetchData = async () => { - setLoading(true); - setError(null); - try { - const response = await fetch(url, { ...options, signal: controller.signal }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to fetch data.'); - } - const result: T = await response.json(); - if (isMounted) { - setData(result); - } - } catch (err: any) { - if (isMounted) { - if (err.name !== 'AbortError') { - setError(err.message); - } - } - } finally { - if (isMounted) setLoading(false); - } - }; - - fetchData(); - - return () => { - isMounted = false; - controller.abort(); - }; - }, [url, JSON.stringify(options)]); - - return { data, loading, error }; -}; - -export default useFetch; diff --git a/app/frontend/hooks/useFetchAreas.ts b/app/frontend/hooks/useFetchAreas.ts deleted file mode 100644 index 68557cf..0000000 --- a/app/frontend/hooks/useFetchAreas.ts +++ /dev/null @@ -1,16 +0,0 @@ -import useSWR from 'swr'; -import { Area } from '../entities/Area'; -import { fetcher } from '../utils/fetcher'; - -const useFetchAreas = () => { - const { data, error, mutate } = useSWR('/api/areas?active=true', fetcher); - - return { - areas: data || [], - isLoading: !error && !data, - isError: !!error, - mutate, - }; -}; - -export default useFetchAreas; diff --git a/app/frontend/hooks/useFetchNotes.ts b/app/frontend/hooks/useFetchNotes.ts deleted file mode 100644 index a75e5ae..0000000 --- a/app/frontend/hooks/useFetchNotes.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useFetch from './useFetch'; -import { Note } from '../entities/Note'; - -const useFetchNotes = () => { - const { data, loading, error } = useFetch('/api/notes', { - credentials: 'include', - headers: { - Accept: 'application/json', - }, - }); - - return { notes: data || [], loading, error }; -}; - -export default useFetchNotes; diff --git a/app/frontend/hooks/useFetchProjects.ts b/app/frontend/hooks/useFetchProjects.ts deleted file mode 100644 index fbf6eef..0000000 --- a/app/frontend/hooks/useFetchProjects.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { useState, useEffect } from 'react'; -import { Project } from '../entities/Project'; - -interface UseFetchProjectsOptions { - activeFilter?: string; - areaFilter?: string; -} - -interface UseFetchProjectsResult { - projects: Project[]; - taskStatusCounts?: any; - isLoading: boolean; - isError: boolean; - mutate: () => void; -} - -const useFetchProjects = (options?: UseFetchProjectsOptions): UseFetchProjectsResult => { - const [projects, setProjects] = useState([]); - const [taskStatusCounts, setTaskStatusCounts] = useState(); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(false); - - const fetchProjects = async () => { - setIsLoading(true); - setIsError(false); - try { - let url = '/api/projects'; - const params = new URLSearchParams(); - - if (options?.activeFilter !== undefined && options.activeFilter !== "all") { - params.append('active', String(options.activeFilter)); - } - if (options?.areaFilter !== undefined) { - params.append('area_id', options.areaFilter); - } - - if (params.toString()) { - url += `?${params.toString()}`; - } - - const response = await fetch(url, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }); - - if (response.ok) { - const data = await response.json(); - - if (data.projects) { - setProjects(data.projects); - setTaskStatusCounts(data.taskStatusCounts); - } else { - setProjects(data); - setTaskStatusCounts(undefined); - } - } else { - throw new Error('Failed to fetch projects.'); - } - } catch (error) { - console.error('Error fetching projects:', error); - setIsError(true); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - fetchProjects(); - }, [options?.activeFilter, options?.areaFilter]); - - return { projects, taskStatusCounts, isLoading, isError, mutate: fetchProjects }; -}; - -export default useFetchProjects; diff --git a/app/frontend/hooks/useFetchTags.ts b/app/frontend/hooks/useFetchTags.ts deleted file mode 100644 index 4334650..0000000 --- a/app/frontend/hooks/useFetchTags.ts +++ /dev/null @@ -1,15 +0,0 @@ -import useSWR from 'swr'; -import { fetcher } from '../utils/fetcher'; - -const useFetchTags = () => { - const { data, error, mutate } = useSWR('/api/tags', fetcher); - - return { - tags: data || [], - isLoading: !data && !error, - isError: !!error, - mutate, - }; -}; - -export default useFetchTags; diff --git a/app/frontend/hooks/useFetchTasks.ts b/app/frontend/hooks/useFetchTasks.ts deleted file mode 100644 index 2d139d1..0000000 --- a/app/frontend/hooks/useFetchTasks.ts +++ /dev/null @@ -1,69 +0,0 @@ -import useSWR from 'swr'; -import { Task } from '../entities/Task'; - -interface UseFetchTasksOptions { - type?: string; - tag?: string; -} - -interface Metrics { - total_open_tasks: number; - tasks_pending_over_month: number; - tasks_in_progress_count: number; - tasks_in_progress: Task[]; - tasks_due_today: Task[]; - suggested_tasks: Task[]; -} - -interface UseFetchTasksResult { - tasks: Task[]; - metrics: Metrics; - isLoading: boolean; - isError: boolean; - mutate: () => void; -} - -const initialMetrics: Metrics = { - total_open_tasks: 0, - tasks_pending_over_month: 0, - tasks_in_progress_count: 0, - tasks_in_progress: [], - tasks_due_today: [], - suggested_tasks: [], -}; - -const fetcher = (url: string) => - fetch(url, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }).then((res) => { - if (!res.ok) { - throw new Error('Failed to fetch tasks.'); - } - return res.json(); - }); - -const useFetchTasks = (options?: UseFetchTasksOptions): UseFetchTasksResult => { - const params = new URLSearchParams(); - - if (options?.type) { - params.append('type', options.type); - } - if (options?.tag) { - params.append('tag', options.tag); - } - - const queryString = params.toString(); - const url = `/api/tasks${queryString ? `?${queryString}` : ''}`; - const { data, error, mutate } = useSWR(url, fetcher); - - return { - tasks: data?.tasks || [], - metrics: data?.metrics || initialMetrics, - isLoading: !error && !data, - isError: !!error, - mutate, - }; -}; - -export default useFetchTasks; diff --git a/app/frontend/hooks/useManageAreas.ts b/app/frontend/hooks/useManageAreas.ts deleted file mode 100644 index 590ab7d..0000000 --- a/app/frontend/hooks/useManageAreas.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback } from 'react'; -import { useSWRConfig } from 'swr'; -import { Area } from '../entities/Area'; - -const useManageAreas = () => { - const { mutate } = useSWRConfig(); - - const createArea = useCallback(async (areaData: Partial) => { - try { - const response = await fetch('/api/areas', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(areaData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create area.'); - } - const newArea: Area = await response.json(); - mutate('/api/areas?active=true', (current: Area[] = []) => [...current, newArea], false); - } catch (error) { - console.error('Error creating area:', error); - throw error; - } - }, [mutate]); - - const updateArea = useCallback(async (areaId: number, areaData: Partial) => { - try { - const response = await fetch(`/api/areas/${areaId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(areaData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update area.'); - } - const updatedArea: Area = await response.json(); - mutate('/api/areas?active=true', (current: Area[] = []) => - current.map(area => (area.id === areaId ? updatedArea : area)), - false - ); - } catch (error) { - console.error('Error updating area:', error); - throw error; - } - }, [mutate]); - - const deleteArea = useCallback(async (areaId: number) => { - try { - const response = await fetch(`/api/areas/${areaId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete area.'); - } - mutate('/api/areas?active=true', (current: Area[] = []) => - current.filter(area => area.id !== areaId), - false - ); - } catch (error) { - console.error('Error deleting area:', error); - throw error; - } - }, [mutate]); - - return { createArea, updateArea, deleteArea }; -}; - -export default useManageAreas; diff --git a/app/frontend/hooks/useManageNotes.ts b/app/frontend/hooks/useManageNotes.ts deleted file mode 100644 index ce3e768..0000000 --- a/app/frontend/hooks/useManageNotes.ts +++ /dev/null @@ -1,92 +0,0 @@ -import useSWR from 'swr'; -import { Note } from '../entities/Note'; -import { fetcher } from '../utils/fetcher'; -import { useCallback } from 'react'; - -const useManageNotes = () => { - const { data, error, mutate } = useSWR('/api/notes', fetcher); - - const createNote = useCallback( - async (noteData: Partial) => { - const noteDataToSend = { - ...noteData, - tags: noteData.tags?.map((tag) => tag.name) || [], - }; - const response = await fetch('/api/note', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(noteDataToSend), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create note.'); - } - - const newNote: Note = await response.json(); - mutate([...(data || []), newNote], false); - }, - [mutate, data] - ); - - const updateNote = useCallback( - async (noteId: number, noteData: Partial) => { - const noteDataToSend = { - ...noteData, - tags: noteData.tags?.map((tag) => tag.name) || [], - }; - const response = await fetch(`/api/note/${noteId}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, - body: JSON.stringify(noteDataToSend), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update note.'); - } - - const updatedNote: Note = await response.json(); - mutate((data || []).map((note) => (note.id === noteId ? updatedNote : note)), false); - }, - [mutate, data] - ); - - const deleteNote = useCallback( - async (noteId: number) => { - const response = await fetch(`/api/note/${noteId}`, { - method: 'DELETE', - credentials: 'include', - headers: { - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete note.'); - } - - mutate((data || []).filter((note) => note.id !== noteId), false); - }, - [mutate, data] - ); - - return { - notes: data || [], - isLoading: !error && !data, - isError: error, - createNote, - updateNote, - deleteNote, - mutate - }; -}; - -export default useManageNotes; diff --git a/app/frontend/hooks/useManageProjects.ts b/app/frontend/hooks/useManageProjects.ts deleted file mode 100644 index a0e91d1..0000000 --- a/app/frontend/hooks/useManageProjects.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback, useState } from 'react'; -import { useSWRConfig } from 'swr'; -import { Project } from '../entities/Project'; - -const useManageProjects = () => { - const { mutate } = useSWRConfig(); - const [projects, setProjects] = useState([]); - - const createProject = async (projectData: Partial): Promise => { - try { - const response = await fetch('/api/project', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(projectData), - }); - if (response.ok) { - const newProject = await response.json(); - setProjects((prevProjects) => [...prevProjects, newProject]); - return newProject; - } else { - throw new Error('Failed to create project.'); - } - } catch (error) { - console.error('Error creating project:', error); - throw error; - } - }; - - const updateProject = useCallback(async (projectId: number, projectData: Partial) => { - try { - const response = await fetch(`/api/project/${projectId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(projectData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update project.'); - } - const updatedProject: Project = await response.json(); - mutate('/api/projects', (current: Project[] = []) => - current.map((project) => (project.id === projectId ? updatedProject : project)), false); - return updatedProject; - } catch (error) { - console.error('Error updating project:', error); - throw error; - } - }, [mutate]); - - const deleteProject = useCallback(async (projectId: number) => { - try { - const response = await fetch(`/api/project/${projectId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete project.'); - } - mutate('/api/projects', (current: Project[] = []) => - current.filter((project) => project.id !== projectId), false); - } catch (error) { - console.error('Error deleting project:', error); - throw error; - } - }, [mutate]); - - return { projects, createProject, updateProject, deleteProject }; -}; - -export default useManageProjects; diff --git a/app/frontend/hooks/useManageTags.ts b/app/frontend/hooks/useManageTags.ts deleted file mode 100644 index 09eef32..0000000 --- a/app/frontend/hooks/useManageTags.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback } from 'react'; -import { useSWRConfig } from 'swr'; -import { Tag } from '../entities/Tag'; - -const useManageTags = () => { - const { mutate } = useSWRConfig(); - - const createTag = useCallback(async (tagData: Partial) => { - try { - const response = await fetch('/api/tag', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tagData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to create tag.'); - } - const newTag: Tag = await response.json(); - mutate('/api/tags', (current: Tag[] = []) => [...current, newTag], false); - } catch (error) { - console.error('Error creating tag:', error); - throw error; - } - }, [mutate]); - - const updateTag = useCallback(async (tagId: number, tagData: Partial) => { - try { - const response = await fetch(`/api/tag/${tagId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tagData), - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to update tag.'); - } - const updatedTag: Tag = await response.json(); - mutate('/api/tags', (current: Tag[] = []) => - current.map(tag => (tag.id === tagId ? updatedTag : tag)), false - ); - } catch (error) { - console.error('Error updating tag:', error); - throw error; - } - }, [mutate]); - - const deleteTag = useCallback(async (tagId: number) => { - try { - const response = await fetch(`/api/tag/${tagId}`, { - method: 'DELETE', - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to delete tag.'); - } - mutate('/api/tags', (current: Tag[] = []) => - current.filter(tag => tag.id !== tagId), false - ); - } catch (error) { - console.error('Error deleting tag:', error); - throw error; - } - }, [mutate]); - - return { createTag, updateTag, deleteTag }; -}; - -export default useManageTags; diff --git a/app/frontend/hooks/useManageTasks.ts b/app/frontend/hooks/useManageTasks.ts deleted file mode 100644 index 9f6c5f1..0000000 --- a/app/frontend/hooks/useManageTasks.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from 'react'; -import { Task } from '../entities/Task'; - -const useManageTasks = () => { - const [tasks, setTasks] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isError, setIsError] = useState(false); - - const fetchTasks = async (query: string = '') => { - setIsLoading(true); - setIsError(false); - try { - const response = await fetch(`/api/tasks${query}`, { - credentials: 'include', - headers: { Accept: 'application/json' }, - }); - if (response.ok) { - const data = await response.json(); - setTasks(data); - } else { - throw new Error('Failed to fetch tasks.'); - } - } catch (error) { - setIsError(true); - } finally { - setIsLoading(false); - } - }; - - const createTask = async (taskData: Partial) => { - try { - const response = await fetch('/api/task', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(taskData), - }); - if (response.ok) { - const newTask = await response.json(); - setTasks((prevTasks) => [newTask, ...prevTasks]); - } else { - throw new Error('Failed to create task.'); - } - } catch (error) { - console.error('Error creating task:', error); - } - }; - - const updateTask = async (taskId: number, taskData: Partial) => { - try { - const response = await fetch(`/api/task/${taskId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(taskData), - }); - if (response.ok) { - const updatedTask = await response.json(); - setTasks((prevTasks) => - prevTasks.map((task) => (task.id === taskId ? updatedTask : task)) - ); - } else { - throw new Error('Failed to update task.'); - } - } catch (error) { - console.error('Error updating task:', error); - } - }; - - const deleteTask = async (taskId: number) => { - try { - const response = await fetch(`/api/task/${taskId}`, { - method: 'DELETE', - credentials: 'include', - }); - if (response.ok) { - setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId)); - } else { - throw new Error('Failed to delete task.'); - } - } catch (error) { - console.error('Error deleting task:', error); - } - }; - - const mutateTasks = fetchTasks; - - return { tasks, isLoading, isError, fetchTasks, mutateTasks, createTask, updateTask, deleteTask }; -}; - -export default useManageTasks; diff --git a/app/frontend/store/useStore.ts b/app/frontend/store/useStore.ts new file mode 100644 index 0000000..029d47f --- /dev/null +++ b/app/frontend/store/useStore.ts @@ -0,0 +1,102 @@ +import { create } from "zustand"; +import { Project } from "../entities/Project"; +import { Area } from "../entities/Area"; +import { Note } from "../entities/Note"; +import { Task } from "../entities/Task"; +import { Tag } from "../entities/Tag"; + +interface NotesStore { + notes: Note[]; + isLoading: boolean; + isError: boolean; + setNotes: (notes: Note[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface AreasStore { + areas: Area[]; + isLoading: boolean; + isError: boolean; + setAreas: (areas: Area[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface ProjectsStore { + projects: Project[]; + isLoading: boolean; + isError: boolean; + setProjects: (projects: Project[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface TagsStore { + tags: Tag[]; + isLoading: boolean; + isError: boolean; + setTags: (tags: Tag[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface TasksStore { + tasks: Task[]; + isLoading: boolean; + isError: boolean; + setTasks: (tasks: Task[]) => void; + setLoading: (isLoading: boolean) => void; + setError: (isError: boolean) => void; +} + +interface StoreState { + notesStore: NotesStore; + areasStore: AreasStore; + projectsStore: ProjectsStore; + tagsStore: TagsStore; + tasksStore: TasksStore; +} + +export const useStore = create((set) => ({ + notesStore: { + notes: [], + isLoading: false, + isError: false, + setNotes: (notes) => set((state) => ({ notesStore: { ...state.notesStore, notes } })), + setLoading: (isLoading) => set((state) => ({ notesStore: { ...state.notesStore, isLoading } })), + setError: (isError) => set((state) => ({ notesStore: { ...state.notesStore, isError } })), + }, + areasStore: { + areas: [], + isLoading: false, + isError: false, + setAreas: (areas) => set((state) => ({ areasStore: { ...state.areasStore, areas } })), + setLoading: (isLoading) => set((state) => ({ areasStore: { ...state.areasStore, isLoading } })), + setError: (isError) => set((state) => ({ areasStore: { ...state.areasStore, isError } })), + }, + projectsStore: { + projects: [], + isLoading: false, + isError: false, + setProjects: (projects) => set((state) => ({ projectsStore: { ...state.projectsStore, projects } })), + setLoading: (isLoading) => set((state) => ({ projectsStore: { ...state.projectsStore, isLoading } })), + setError: (isError) => set((state) => ({ projectsStore: { ...state.projectsStore, isError } })), + }, + tagsStore: { + tags: [], + isLoading: false, + isError: false, + setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })), + setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })), + setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })), + }, + tasksStore: { + tasks: [], + isLoading: false, + isError: false, + setTasks: (tasks) => set((state) => ({ tasksStore: { ...state.tasksStore, tasks } })), + setLoading: (isLoading) => set((state) => ({ tasksStore: { ...state.tasksStore, isLoading } })), + setError: (isError) => set((state) => ({ tasksStore: { ...state.tasksStore, isError } })), + }, +})); \ No newline at end of file diff --git a/app/frontend/utils/areasService.ts b/app/frontend/utils/areasService.ts new file mode 100644 index 0000000..be60bf0 --- /dev/null +++ b/app/frontend/utils/areasService.ts @@ -0,0 +1,40 @@ +import { Area } from "../entities/Area"; + +export const fetchAreas = async (): Promise => { + const response = await fetch("/api/areas?active=true"); + if (!response.ok) throw new Error('Failed to fetch areas.'); + + return await response.json(); +}; + +export const createArea = async (areaData: Partial): Promise => { + const response = await fetch('/api/areas', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(areaData), + }); + + if (!response.ok) throw new Error('Failed to create area.'); + + return await response.json(); +}; + +export const updateArea = async (areaId: number, areaData: Partial): Promise => { + const response = await fetch(`/api/areas/${areaId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(areaData), + }); + + if (!response.ok) throw new Error('Failed to update area.'); + + return await response.json(); +}; + +export const deleteArea = async (areaId: number): Promise => { + const response = await fetch(`/api/areas/${areaId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete area.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/notesService.ts b/app/frontend/utils/notesService.ts new file mode 100644 index 0000000..060af6b --- /dev/null +++ b/app/frontend/utils/notesService.ts @@ -0,0 +1,40 @@ +import { Note } from "../entities/Note"; + +export const fetchNotes = async (): Promise => { + const response = await fetch("/api/notes"); + if (!response.ok) throw new Error('Failed to fetch notes.'); + + return await response.json(); +}; + +export const createNote = async (noteData: Note): Promise => { + const response = await fetch('/api/notes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(noteData), + }); + + if (!response.ok) throw new Error('Failed to create note.'); + + return await response.json(); +}; + +export const updateNote = async (noteId: number, noteData: Note): Promise => { + const response = await fetch(`/api/notes/${noteId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(noteData), + }); + + if (!response.ok) throw new Error('Failed to update note.'); + + return await response.json(); +}; + +export const deleteNote = async (noteId: number): Promise => { + const response = await fetch(`/api/notes/${noteId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete note.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/projectsService.ts b/app/frontend/utils/projectsService.ts new file mode 100644 index 0000000..2f18a35 --- /dev/null +++ b/app/frontend/utils/projectsService.ts @@ -0,0 +1,63 @@ +import { Project } from "../entities/Project"; + +export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise => { + let url = `/api/projects`; + const params = new URLSearchParams(); + + if (activeFilter !== "all") params.append("active", activeFilter); + if (areaFilter) params.append("area_id", areaFilter); + if (params.toString()) url += `?${params.toString()}`; + + const response = await fetch(url, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) throw new Error('Failed to fetch projects.'); + + const data = await response.json(); + return data.projects || data; +}; + +export const fetchProjectById = async (projectId: string): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + credentials: 'include', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) throw new Error('Failed to fetch project details.'); + + return await response.json(); +}; + +export const createProject = async (projectData: Partial): Promise => { + const response = await fetch('/api/project', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) throw new Error('Failed to create project.'); + + return await response.json(); +}; + +export const updateProject = async (projectId: number, projectData: Partial): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(projectData), + }); + + if (!response.ok) throw new Error('Failed to update project.'); + + return await response.json(); +}; + +export const deleteProject = async (projectId: number): Promise => { + const response = await fetch(`/api/project/${projectId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete project.'); +}; diff --git a/app/frontend/utils/tagsService.ts b/app/frontend/utils/tagsService.ts new file mode 100644 index 0000000..ba535b8 --- /dev/null +++ b/app/frontend/utils/tagsService.ts @@ -0,0 +1,40 @@ +import { Tag } from "../entities/Tag"; + +export const fetchTags = async (): Promise => { + const response = await fetch("/api/tags"); + if (!response.ok) throw new Error('Failed to fetch tags.'); + + return await response.json(); +}; + +export const createTag = async (tagData: Tag): Promise => { + const response = await fetch('/api/tag', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tagData), + }); + + if (!response.ok) throw new Error('Failed to create tag.'); + + return await response.json(); +}; + +export const updateTag = async (tagId: number, tagData: Tag): Promise => { + const response = await fetch(`/api/tag/${tagId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tagData), + }); + + if (!response.ok) throw new Error('Failed to update tag.'); + + return await response.json(); +}; + +export const deleteTag = async (tagId: number): Promise => { + const response = await fetch(`/api/tag/${tagId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete tag.'); +}; \ No newline at end of file diff --git a/app/frontend/utils/tasksService.ts b/app/frontend/utils/tasksService.ts new file mode 100644 index 0000000..467df67 --- /dev/null +++ b/app/frontend/utils/tasksService.ts @@ -0,0 +1,52 @@ +import { Metrics } from "../entities/Metrics"; +import { Task } from "../entities/Task"; + +export const fetchTasks = async (query = ''): Promise<{ tasks: Task[]; metrics: Metrics }> => { + const response = await fetch(`/api/tasks${query}`); + + if (!response.ok) throw new Error('Failed to fetch tasks.'); + + const result = await response.json(); + + if (!Array.isArray(result.tasks)) { + throw new Error('Resulting tasks are not an array.'); + } + + if (!result.metrics) { + throw new Error('Metrics data is not included.'); + } + + return { tasks: result.tasks, metrics: result.metrics }; +}; + +export const createTask = async (taskData: Task): Promise => { + const response = await fetch('/api/task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(taskData), + }); + + if (!response.ok) throw new Error('Failed to create task.'); + + return await response.json(); +}; + +export const updateTask = async (taskId: number, taskData: Task): Promise => { + const response = await fetch(`/api/task/${taskId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(taskData), + }); + + if (!response.ok) throw new Error('Failed to update task.'); + + return await response.json(); +}; + +export const deleteTask = async (taskId: number): Promise => { + const response = await fetch(`/api/task/${taskId}`, { + method: 'DELETE', + }); + + if (!response.ok) throw new Error('Failed to delete task.'); +}; diff --git a/app/models/project.rb b/app/models/project.rb index 6059ef0..db73f0f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -32,4 +32,8 @@ class Project < ActiveRecord::Base completed_tasks = counts[:total] - counts[:not_started] (completed_tasks.to_f / counts[:total] * 100).round end + + def due_date_at + self[:due_date_at]&.strftime('%Y-%m-%d') + end end diff --git a/app/models/task.rb b/app/models/task.rb index a617036..9d86ffd 100644 --- a/app/models/task.rb +++ b/app/models/task.rb @@ -76,47 +76,103 @@ class Task < ActiveRecord::Base def self.compute_metrics(user) total_open_tasks = user.tasks.incomplete.count - one_month_ago = Date.today - 30 tasks_pending_over_month = user.tasks.incomplete.where('created_at < ?', one_month_ago).count - tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]) + tasks_in_progress = user.tasks.incomplete.where(status: statuses[:in_progress]).order(priority: :desc) tasks_in_progress_count = tasks_in_progress.count - tasks_due_today = user.tasks.due_today + # Calculate tasks due today including those due via projects + tasks_due_today = user.tasks.incomplete.joins(:project) + .where('tasks.due_date <= ? OR projects.due_date_at <= ?', Date.today, Date.today) + .distinct - # Exclude tasks that are in progress or due today + # Gather an array of IDs to be excluded from suggested tasks excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id) - # Fetch suggested tasks not in projects, ordered by task priority + # Gather tasks in projects expiring starting today, order by task priority + tasks_in_expiring_projects = user.tasks.incomplete + .joins(:project) + .where('projects.due_date_at >= ?', Date.today) + .where(projects: { active: true }) # Only active projects + .where.not(id: excluded_task_ids) + .order(Arel.sql('projects.due_date_at ASC, tasks.priority DESC')) + .limit(5) + + # Gather tasks not assigned to projects expiring today, ordered by task priority tasks_without_projects = user.tasks.incomplete - .where(status: statuses[:not_started], project_id: nil) - .where.not(id: excluded_task_ids) - .order(priority: :desc) - .limit(3) + .where(status: statuses[:not_started], project_id: nil) + .or(user.tasks.where(project_id: nil, status: statuses[:not_started])) + .where.not(id: excluded_task_ids) + .order(priority: :desc) + .limit(5) - # Fetch suggested tasks in projects, ordered by task priority and project priority - tasks_in_projects = user.tasks.incomplete - .where(status: statuses[:not_started]) - .where.not(project_id: nil) - .where.not(id: excluded_task_ids) - .joins('LEFT JOIN projects ON tasks.project_id = projects.id') - .order( - Arel.sql('tasks.priority DESC, projects.priority DESC') - ) - .distinct - .limit(3) + # Combine both list of suggested tasks + suggested_tasks = sort_suggested_tasks(tasks_in_expiring_projects + tasks_without_projects) { total_open_tasks: total_open_tasks, tasks_pending_over_month: tasks_pending_over_month, tasks_in_progress: tasks_in_progress, tasks_in_progress_count: tasks_in_progress_count, tasks_due_today: tasks_due_today, - suggested_tasks: (tasks_without_projects + tasks_in_projects) + suggested_tasks: suggested_tasks } end + def self.sort_suggested_tasks(tasks) + tasks.sort_by do |task| + # Parse or default the task due date + task_due_date = if task.due_date.is_a?(String) + Date.parse(task.due_date) + else + task.due_date || Date.new(9999, 12, 31) + end + + # Parse or default the project due date + project_due_date = if (task.project&.due_date_at).is_a?(String) + Date.parse(task&.project&.due_date_at) + else + task.project&.due_date_at || Date.new(9999, 12, 31) + end + + # Priority in descending order (sorted values should be negative for sort_by) + priority_value = -(Task.priorities.fetch(task.priority, -1)) + + # Determine sorting flags based on various criteria + is_high_priority_proj_with_due_date = (task.priority == 'high' && task&.project&.due_date_at) ? 0 : 1 + is_high_priority_with_due_date = (task.priority == 'high' && task.due_date) ? 0 : 1 + is_high_priority = (task.priority == 'high' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + is_medium_priority_proj_with_due_date = (task.priority == 'medium' && task&.project&.due_date_at) ? 0 : 1 + is_medium_priority_with_due_date = (task.priority == 'medium' && task.due_date) ? 0 : 1 + is_medium_priority = (task.priority == 'medium' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + is_low_priority_proj_with_due_date = (task.priority == 'low' && task&.project&.due_date_at) ? 0 : 1 + is_low_priority_with_due_date = (task.priority == 'low' && task.due_date) ? 0 : 1 + is_low_priority = (task.priority == 'low' && !task.due_date && !task&.project&.due_date_at) ? 0 : 1 + + # Primary sorting criteria + [ + is_high_priority_proj_with_due_date, + is_high_priority_with_due_date, + is_high_priority, + + is_medium_priority_proj_with_due_date, + is_medium_priority_with_due_date, + is_medium_priority, + + is_low_priority_proj_with_due_date, + is_low_priority_with_due_date, + is_low_priority, + + task_due_date, + project_due_date, + priority_value + ] + end + end + def as_json(options = {}) super(options).merge( 'due_date' => due_date&.strftime('%Y-%m-%d') diff --git a/app/routes/projects_routes.rb b/app/routes/projects_routes.rb index 96cdbc4..3a53f76 100644 --- a/app/routes/projects_routes.rb +++ b/app/routes/projects_routes.rb @@ -75,7 +75,8 @@ class Sinatra::Application area_id: project_data['area_id'], active: true, pin_to_sidebar: false, - priority: project_data['priority'] + priority: project_data['priority'], + due_date_at: project_data['due_date_at'] ) if project.save @@ -106,7 +107,8 @@ class Sinatra::Application area_id: project_data['area_id'], active: project_data['active'], pin_to_sidebar: project_data['pin_to_sidebar'], - priority: project_data ['priority'] + priority: project_data ['priority'], + due_date_at: project_data['due_date_at'] ) if project.save diff --git a/app/views/index.erb b/app/views/index.erb index 89ff92b..3083eee 100644 --- a/app/views/index.erb +++ b/app/views/index.erb @@ -5,6 +5,6 @@ - +
{description}