Merge pull request #50 from chrisvel/add-state-management

Add global state management library
This commit is contained in:
Chris 2025-03-27 07:41:29 +02:00 committed by GitHub
commit 88d51f6d25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1387 additions and 1590 deletions

View file

@ -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 (
<DataProvider>
<>
{currentUser ? (
<Layout
currentUser={currentUser}
@ -131,7 +130,7 @@ const App: React.FC = () => {
) : (
<Login />
)}
</DataProvider>
</>
);
};

View file

@ -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<LayoutProps> = ({
const [newTask, setNewTask] = useState<Task | null>(null);
const {
tags,
areas,
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,
isLoading,
isError,
createNote,
updateNote,
deleteNote,
createArea,
updateArea,
deleteArea,
createTag,
updateTag,
deleteTag,
createProject,
updateProject,
deleteProject,
createTask,
updateTask,
} = useDataContext();
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<boolean>(
window.innerWidth >= 1024
@ -77,6 +102,37 @@ const Layout: React.FC<LayoutProps> = ({
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<LayoutProps> = ({
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<LayoutProps> = ({
} 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<Project> => {
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<LayoutProps> = ({
} 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<Project> => {
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<Area>) => {
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<LayoutProps> = ({
} 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<LayoutProps> = ({
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
const isLoading =
isNotesLoading ||
isAreasLoading ||
isTasksLoading ||
isProjectsLoading ||
isTagsLoading;
const isError =
isNotesError ||
isAreasError ||
isTasksError ||
isProjectsError ||
isTagsError;
if (isLoading) {
return (
<div className={`min-h-screen ${isDarkMode ? "dark" : ""}`}>
@ -402,4 +472,3 @@ const Layout: React.FC<LayoutProps> = ({
};
export default Layout;

View file

@ -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<any | null>(null);
const { areas } = useStore((state) => state.areasStore);
const [area, setArea] = useState<Area | null>(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 (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="textpro-red-500 text-lg">
<div className="text-red-500 text-lg">
{isError ? 'Error loading area details.' : 'Area not found.'}
</div>
</div>

View file

@ -1,23 +1,21 @@
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<Area>) => Promise<void>;
area?: Area | null;
}
const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave }) => {
const { createArea, updateArea } = useDataContext();
const [formData, setFormData] = useState<Area>({
id: area?.id || 0,
name: area?.name || '',
description: area?.description || '',
});
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
@ -60,6 +58,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
@ -88,14 +87,8 @@ const AreaModal: React.FC<AreaModalProps> = ({ 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,17 +109,12 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
if (!isOpen) return null;
return (
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
}`}
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
>
<div
ref={modalRef}
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-md overflow-hidden transform transition-transform duration-300 ${
isClosing ? 'scale-95' : 'scale-100'
} h-screen sm:h-auto flex flex-col`}
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-md 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)',
}}
@ -181,9 +169,7 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out ${
isSubmitting ? 'opacity-50 cursor-not-allowed' : ''
}`}
className={`px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 focus:outline-none transition duration-150 ease-in-out ${isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isSubmitting
? 'Submitting...'
@ -196,7 +182,6 @@ const AreaModal: React.FC<AreaModalProps> = ({ isOpen, onClose, area, onSave })
</form>
</div>
</div>
</>
);
};

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
PencilSquareIcon,
@ -7,17 +7,34 @@ import {
} from '@heroicons/react/24/solid';
import ConfirmDialog from './Shared/ConfirmDialog';
import AreaModal from './Area/AreaModal';
import { useDataContext } from '../contexts/DataContext';
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<boolean>(false);
const [selectedArea, setSelectedArea] = useState<Area | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [areaToDelete, setAreaToDelete] = useState<Area | null>(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<Area>) => {
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 (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
Loading areas...
</div>
</div>
);
}
if (isError) {
return (
<div className="text-red-500 p-4">
An error occurred while fetching areas.
</div>
);
}
return (
<div className="flex justify-center px-4 lg:px-2 ">
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Areas Header */}
<div className="flex items-center justify-between mb-4">
@ -99,6 +108,12 @@ const Areas: React.FC = () => {
Areas
</h2>
</div>
<button
onClick={handleCreateArea}
className="bg-blue-500 text-white rounded-md px-4 py-2 hover:bg-blue-600"
>
Add Area
</button>
</div>
{/* Areas List */}

View file

@ -1,38 +1,62 @@
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 { 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<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
useEffect(() => {
const foundNote = notes.find((n) => n.id === Number(id));
const fetchNote = async () => {
try {
setIsLoading(true);
const notes = await fetchNotes();
const foundNote = notes.find((n: Note) => n.id === Number(id));
setNote(foundNote || null);
}, [id, notes]);
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!);
await apiDeleteNote(noteToDelete.id!);
navigate('/notes');
} catch (err) {
console.error('Error deleting note:', err);
}
};
const handleSaveNote = (updatedNote: Note) => {
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);
};
@ -66,7 +90,7 @@ const NoteDetails: React.FC = () => {
}
return (
<div className="flex justify-center px-4 lg:px-2 ">
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Header Section with Title and Action Buttons */}
<div className="flex items-center justify-between mb-4">
@ -76,7 +100,6 @@ const NoteDetails: React.FC = () => {
{note.title}
</h2>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
@ -97,7 +120,6 @@ const NoteDetails: React.FC = () => {
</button>
</div>
</div>
{/* Card with Tags and Metadata */}
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
{/* 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"
>
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
<span className="text-xs text-gray-700 dark:text-gray-300">{tag.name}</span>
<span className="text-xs text-gray-700 dark:text-gray-300">
{tag.name}
</span>
</button>
))}
</div>
</div>
)}
{/* Note Metadata */}
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
</div>
{/* Note Project */}
{note.project && (
<div className="mt-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Project</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Project
</h3>
<Link
to={`/project/${note.project.id}`}
className="text-blue-600 dark:text-blue-400 hover:underline"
@ -137,14 +161,12 @@ const NoteDetails: React.FC = () => {
</div>
)}
</div>
{/* Note Content */}
<div className="mb-6">
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
{note.content}
</p>
</div>
{/* NoteModal for editing */}
{isNoteModalOpen && (
<NoteModal
@ -154,7 +176,6 @@ const NoteDetails: React.FC = () => {
note={note}
/>
)}
{/* ConfirmDialog */}
{isConfirmDialogOpen && noteToDelete && (
<ConfirmDialog

View file

@ -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<void>;
}
const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave }) => {
const { createNote, updateNote } = useDataContext();
const [formData, setFormData] = useState<Note>({
id: note?.id || 0,
title: note?.title || '',
@ -30,14 +29,18 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
const { showSuccessToast, showErrorToast } = useToast();
useEffect(() => {
if (isOpen) {
fetch('/api/tags')
.then((response) => response.json())
.then((data: Tag[]) => setAvailableTags(data))
.catch((error) => {
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) {
loadTags();
}
}, [isOpen, showErrorToast]);
@ -78,6 +81,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
@ -114,14 +118,9 @@ const NoteModal: React.FC<NoteModalProps> = ({ 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,7 +141,6 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
if (!isOpen) return null;
return (
<>
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? 'opacity-0' : 'opacity-100'
@ -231,7 +229,6 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
</form>
</div>
</div>
</>
);
};

View file

@ -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<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
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 (
<div className="flex justify-center px-4 lg:px-2 ">
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Notes Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<BookOpenIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
<h2 className="text-2xl font-light text-gray-900 dark:text-white">Notes</h2>
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
Notes
</h2>
</div>
</div>
@ -99,7 +137,10 @@ const Notes: React.FC = () => {
) : (
<ul className="space-y-2">
{filteredNotes.map((note) => (
<li key={note.id} className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center">
<li
key={note.id}
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
>
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/note/${note.id}`}

View file

@ -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<PriorityType, string> & { 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<Project | undefined>(undefined);
const [tasks, setTasks] = useState<Task[]>([]);
@ -36,71 +39,51 @@ const ProjectDetails: React.FC = () => {
const [error, setError] = useState<string | null>(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 () => {
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.");
const loadProjectData = async () => {
if (!id) {
console.error("Project ID is missing.");
return;
}
setLoading(true);
try {
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 = {
try {
const newTask = await createTask({
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 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,8 +117,8 @@ 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;
}
@ -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,8 +171,8 @@ 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 */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
<FolderIcon className="h-6 w-6 text-gray-500 mr-3" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
{projectTitle}
{project.name}
</h2>
{/* Priority Circle placed after the title */}
{project.priority && (
<div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${priorityStyles[project.priority] || priorityStyles.default}`}
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
)}
</div>
<div className="flex space-x-2">
{/* Edit Project Button */}
<button
onClick={handleEditProject}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
{/* Delete Project Button */}
<button
onClick={() => 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 = () => {
</div>
</div>
{/* Project Area */}
{project.area && (
<div className="flex items-center mb-4">
<div className="flex items-center mb-2">
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<Link
to={`/projects/?area_id=${project.area.id}`}
@ -246,19 +226,22 @@ const ProjectDetails: React.FC = () => {
</div>
)}
{/* Project Description */}
{project.due_date_at && (
<div className="flex items-center mb-2">
<CalendarDaysIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
{project.due_date_at}
</div>
)}
{project.description && (
<p className="text-gray-700 dark:text-gray-300 mb-6">
<p className="flex items-center text-gray-700 dark:text-gray-300 mb-6">
<InformationCircleIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
{project.description}
</p>
)}
{/* New Task Form */}
<NewTask
onTaskCreate={handleTaskCreate}
/>
<NewTask onTaskCreate={handleTaskCreate} />
{/* Active Tasks */}
<div className="mt-2">
{activeTasks.length > 0 ? (
<TaskList
@ -272,7 +255,6 @@ const ProjectDetails: React.FC = () => {
)}
</div>
{/* Collapsible Completed Tasks */}
<div className="mt-6">
<button
onClick={toggleCompleted}
@ -298,7 +280,7 @@ const ProjectDetails: React.FC = () => {
{isCompletedOpen && (
<div className="mt-4">
{completedTasks.length > 0 ? (
{completedTasks && completedTasks.length > 0 ? (
<TaskList
tasks={completedTasks}
onTaskUpdate={handleTaskUpdate}
@ -314,7 +296,6 @@ const ProjectDetails: React.FC = () => {
)}
</div>
{/* Modals */}
<ProjectModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
@ -323,7 +304,6 @@ const ProjectDetails: React.FC = () => {
areas={areas}
/>
{/* Confirm Delete Dialog */}
{isConfirmDialogOpen && (
<ConfirmDialog
title="Delete Project"

View file

@ -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<ProjectModalProps> = ({
active: true,
tags: [],
priority: "low",
due_date_at: "",
}
);
@ -41,23 +43,21 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
project?.tags?.map((tag) => tag.name) || []
);
const {
tags: availableTags,
isLoading: isTagsLoading,
isError: isTagsError,
} = useFetchTags();
const { tagsStore } = useStore();
const { tags: availableTags } = tagsStore;
const modalRef = useRef<HTMLDivElement>(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<ProjectModalProps> = ({
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<ProjectModalProps> = ({
if (!isOpen) return null;
if (isTagsLoading) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Loading tags...
</div>
</div>
);
}
if (isTagsError) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-50">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Error loading tags.
</div>
</div>
);
}
return (
<>
{/* Modal Overlay */}
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100"
}`}
>
{/* Modal Content */}
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl overflow-hidden transform transition-transform duration-300 ${
@ -214,12 +200,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
maxHeight: "calc(100vh - 4rem)",
}}
>
{/* Form */}
<form className="flex flex-col flex-1">
<fieldset className="flex flex-col flex-1">
{/* Form Fields */}
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
{/* Project Name */}
<div className="py-4">
<input
type="text"
@ -233,7 +216,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
/>
</div>
{/* Description */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
@ -249,6 +231,19 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
></textarea>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Due Date
</label>
<input
type="date"
name="due_date_at"
value={formData.due_date_at || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
/>
</div>
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Priority
@ -261,7 +256,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
/>
</div>
{/* Tags */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
@ -275,7 +269,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
</div>
{/* Area */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Area (optional)
@ -296,7 +289,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</select>
</div>
{/* Active Checkbox */}
<div className="flex items-center">
<Switch
isChecked={formData.active}
@ -311,7 +303,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
</div>
{/* Action Buttons */}
<div className="p-3 flex-shrink-0 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-2">
{project && onDelete && (
<button
@ -342,7 +333,6 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
</div>
{/* Confirmation Dialog for Deletion */}
{showConfirmDialog && (
<ConfirmDialog
title="Delete Project"

View file

@ -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<Record<number, ProjectTaskCounts>>({});
const [isProjectModalOpen, setIsProjectModalOpen] = useState<boolean>(false);
const [projectToEdit, setProjectToEdit] = useState<Project | null>(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<HTMLSelectElement>) => {
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<Record<string, Project[]>>(
(acc, project) => {
const areaName = project.area ? project.area.name : "Uncategorized";
if (!acc[areaName]) acc[areaName] = [];
acc[areaName].push(project);
return acc;
},
{}
);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
@ -139,16 +193,6 @@ const Projects: React.FC = () => {
);
}
const groupedProjects = filteredProjects.reduce<Record<string, Project[]>>(
(acc, project) => {
const areaName = project.area ? project.area.name : "Uncategorized";
if (!acc[areaName]) acc[areaName] = [];
acc[areaName].push(project);
return acc;
},
{}
);
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-6xl">
@ -222,7 +266,7 @@ const Projects: React.FC = () => {
>
<option value="">All Areas</option>
{areas.map((area) => (
<option key={area.id} value={area.id.toString()}>
<option key={area.id} value={area.id?.toString()}>
{area.name}
</option>
))}
@ -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 ? (

View file

@ -1,4 +1,3 @@
// Switch.tsx
import React from 'react';
interface SwitchProps {

View file

@ -85,7 +85,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
};
const addNewTag = (tag: string) => {
if (tags.length >= 10) { // Example limit
if (tags.length >= 10) {
return;
}

View file

@ -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<Tag[]>([]);
const [isTagModalOpen, setIsTagModalOpen] = useState<boolean>(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(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,8 +78,7 @@ const Tags: React.FC = () => {
setTagToDelete(null);
};
const filteredTags = tags.filter(
(tag) =>
const filteredTags = tags.filter((tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
);
@ -71,11 +93,11 @@ const Tags: React.FC = () => {
}
if (isError) {
return <div className="text-red-500 p-4">Error loading tags</div>;
return <div className="text-red-500 p-4">Error loading tags.</div>;
}
return (
<div className="flex justify-center px-4 lg:px-2 ">
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Tags Header */}
<div className="flex items-center justify-between mb-8">
@ -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 */}
<div className="flex-grow overflow-hidden pr-4">
<Link
to={`/tag/${tag.id}`}
@ -119,7 +140,6 @@ const Tags: React.FC = () => {
</Link>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<button
onClick={() => handleEditTag(tag)}

View file

@ -14,7 +14,6 @@ interface TaskListProps {
const TaskList: React.FC<TaskListProps> = ({
tasks,
onTaskUpdate,
onTaskCreate,
onTaskDelete,
projects,
}) => {

View file

@ -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,9 +30,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
onCreateProject,
}) => {
const [formData, setFormData] = useState<Task>(task);
const [tags, setTags] = useState<string[]>(
task.tags?.map((tag) => tag.name) || []
);
const [tags, setTags] = useState<string[]>(task.tags?.map((tag) => tag.name) || []);
const [filteredProjects, setFilteredProjects] = useState<Project[]>(projects);
const [newProjectName, setNewProjectName] = useState<string>("");
const [isCreatingProject, setIsCreatingProject] = useState(false);
@ -40,26 +38,42 @@ const TaskModal: React.FC<TaskModalProps> = ({
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const { showSuccessToast, showErrorToast } = useToast();
const { tags: availableTags, isLoading, isError } = useFetchTags();
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<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
) => {
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<TaskModalProps> = ({
setNewProjectName(query);
setDropdownOpen(true);
setFilteredProjects(
projects.filter((project) => project.name.toLowerCase().includes(query))
projects.filter((project) =>
project.name.toLowerCase().includes(query)
)
);
};
@ -137,10 +153,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
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<TaskModalProps> = ({
if (!isOpen) return null;
if (isLoading) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-40">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Loading tags...
</div>
</div>
);
}
if (isError) {
return (
<div className="fixed top-16 left-0 right-0 bottom-0 flex items-center justify-center bg-gray-900 bg-opacity-80 z-40">
<div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-lg">
Error loading tags.
</div>
</div>
);
}
return (
<>
<div
@ -200,14 +193,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
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)" }}
>
<form className="flex flex-col flex-1">
<fieldset className="flex flex-col flex-1">
<div className="p-4 space-y-3 flex-1 text-sm overflow-y-auto">
{/* Task Name */}
<div className="py-4">
<input
type="text"
@ -220,8 +210,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
placeholder="Add Task Name"
/>
</div>
{/* Tags */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
@ -234,8 +222,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
/>
</div>
</div>
{/* Project */}
<div className="pb-3 relative">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Project
@ -280,8 +266,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
</div>
)}
</div>
{/* Status and Priority */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
@ -319,8 +303,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
/>
</div>
</div>
{/* Note */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
Note
@ -336,8 +318,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
></textarea>
</div>
</div>
{/* Task Actions */}
<div className="p-3 flex-shrink-0">
<TaskActions
taskId={task.id}
@ -350,7 +330,6 @@ const TaskModal: React.FC<TaskModalProps> = ({
</form>
</div>
</div>
{showConfirmDialog && (
<ConfirmDialog
title="Delete Task"

View file

@ -1,81 +1,101 @@
import React from "react";
import React, { useEffect } from "react";
import { format } from "date-fns";
import {
ClipboardDocumentListIcon,
ClockIcon,
ArrowPathIcon,
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<Metrics>({
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);
}
updateTask(updatedTask.id, updatedTask)
.then(() => {
mutateTasks();
})
.catch((error) => {
};
loadData();
}, [setProjects, setProjectsLoading, setProjectsError, setTasks, setTasksLoading, setTasksError]);
const handleTaskUpdate = async (updatedTask: Task): Promise<void> => {
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);
}
};
const handleTaskDelete = (taskId: number): void => {
deleteTask(taskId)
.then(() => {
mutateTasks();
})
.catch((error) => {
const handleTaskDelete = async (taskId: number): Promise<void> => {
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 <p>Loading...</p>;
}
if (errorTasks) {
return <p className="text-red-500">Error loading tasks.</p>;
}
if (errorProjects) {
return <p className="text-red-500">Error loading projects.</p>;
}
const todayDate = format(new Date(), "yyyy-MM-dd");
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
{/* Header */}
<div className="flex items-center mb-4">
<h2 className="text-2xl font-light flex items-center">
<CalendarDaysIcon className="h-5 w-5 mr-2" /> Today
@ -85,29 +105,17 @@ const TasksToday: React.FC = () => {
</span>
</div>
{/* Overview of Tasks */}
<div className="mb-6 grid grid-cols-1 sm:grid-cols-4 gap-4">
{/* Total Open Tasks */}
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
<ClipboardDocumentListIcon className="h-8 w-8 text-blue-500 mr-4" />
<div>
<p className="text-gray-500 dark:text-gray-400">Backlog</p>
<p className="text-2xl font-semibold">{metrics.total_open_tasks}</p>
</div>
</div>
{/* Tasks Pending Over a Month */}
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
<ClockIcon className="h-8 w-8 text-yellow-500 mr-4" />
<div>
<p className="text-gray-500 dark:text-gray-400">Stale</p>
<p className="text-2xl font-semibold">
{metrics.tasks_pending_over_month}
{metrics.total_open_tasks}
</p>
</div>
</div>
{/* Tasks In Progress */}
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
<ArrowPathIcon className="h-8 w-8 text-green-500 mr-4" />
<div>
@ -118,7 +126,6 @@ const TasksToday: React.FC = () => {
</div>
</div>
{/* Tasks Due Today */}
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
<CalendarDaysIcon className="h-8 w-8 text-red-500 mr-4" />
<div>
@ -128,12 +135,21 @@ const TasksToday: React.FC = () => {
</p>
</div>
</div>
<div className="p-4 bg-white dark:bg-gray-900 rounded-lg shadow flex items-center">
<ClockIcon className="h-8 w-8 text-yellow-500 mr-4" />
<div>
<p className="text-gray-500 dark:text-gray-400">Stale</p>
<p className="text-2xl font-semibold">
{metrics.tasks_pending_over_month}
</p>
</div>
</div>
</div>
{/* Tasks Due Today */}
{metrics.tasks_due_today.length > 0 && (
<>
<h3 className="text-xl font-medium mt-6 mb-2">Tasks Due Today</h3>
<h3 className="text-xl font-medium mt-6 mb-2">Due Today</h3>
<TaskList
tasks={metrics.tasks_due_today}
onTaskUpdate={handleTaskUpdate}
@ -143,10 +159,9 @@ const TasksToday: React.FC = () => {
</>
)}
{/* Tasks In Progress */}
{metrics.tasks_in_progress.length > 0 && (
<>
<h3 className="text-xl font-medium mt-6 mb-2">Tasks In Progress</h3>
<h3 className="text-xl font-medium mt-6 mb-2">In Progress</h3>
<TaskList
tasks={metrics.tasks_in_progress}
onTaskUpdate={handleTaskUpdate}
@ -156,10 +171,9 @@ const TasksToday: React.FC = () => {
</>
)}
{/* Suggested Tasks */}
{metrics.suggested_tasks.length > 0 && (
<>
<h3 className="text-xl font-medium mt-6 mb-2">Suggested Tasks</h3>
<h3 className="text-xl font-medium mt-6 mb-2">Suggested</h3>
<TaskList
tasks={metrics.suggested_tasks}
onTaskUpdate={handleTaskUpdate}
@ -169,7 +183,6 @@ const TasksToday: React.FC = () => {
</>
)}
{/* Fallback Message */}
{tasks.length === 0 && (
<p className="text-gray-500 text-center mt-4">
No tasks available for today.

View file

@ -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<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>("due_date:asc");
const dropdownRef = useRef<HTMLDivElement>(null);
const [taskSearchQuery, setTaskSearchQuery] = useState<string>("");
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-5xl">
@ -268,10 +274,25 @@ const Tasks: React.FC = () => {
</div>
</div>
{/* Description */}
<p className="mb-6 text-sm text-gray-500 dark:text-gray-400">
{description}
</p>
{/* Search Bar */}
<div className="mb-4">
<div className="flex items-center bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm p-2">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<input
type="text"
placeholder="Search tasks..."
value={taskSearchQuery}
onChange={(e) => setTaskSearchQuery(e.target.value)}
className="w-full bg-transparent border-none focus:ring-0 focus:outline-none dark:text-white"
/>
</div>
</div>
{loading ? (
<p>Loading...</p>
) : error ? (
@ -288,9 +309,9 @@ const Tasks: React.FC = () => {
)}
{/* Task List */}
{tasks.length > 0 ? (
{filteredTasks.length > 0 ? (
<TaskList
tasks={tasks}
tasks={filteredTasks}
onTaskCreate={handleTaskCreate}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}

View file

@ -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<void>;
updateNote: (noteId: number, noteData: any) => Promise<void>;
deleteNote: (noteId: number) => Promise<void>;
createArea: (areaData: any) => Promise<void>;
updateArea: (areaId: number, areaData: any) => Promise<void>;
deleteArea: (areaId: number) => Promise<void>;
createProject: (projectData: any) => Promise<Project>;
updateProject: (projectId: number, projectData: any) => Promise<Project>;
deleteProject: (projectId: number) => Promise<void>;
createTag: (tagData: any) => Promise<void>;
updateTag: (tagId: number, tagData: any) => Promise<void>;
deleteTag: (tagId: number) => Promise<void>;
createTask: (taskData: any) => Promise<void>;
updateTask: (taskId: number, taskData: any) => Promise<void>;
deleteTask: (taskId: number) => Promise<void>;
mutateTags: () => void;
mutateAreas: () => void;
mutateNotes: () => void;
mutateProjects: () => void;
}
const DataContext = createContext<DataContextProps | undefined>(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 (
<DataContext.Provider
value={{
tasks,
tags,
areas,
notes,
projects,
isLoading,
isError,
createNote,
updateNote,
deleteNote,
createArea,
updateArea,
deleteArea,
createProject,
updateProject,
deleteProject,
createTag,
updateTag,
deleteTag,
createTask,
updateTask,
deleteTask,
mutateTags,
mutateAreas,
mutateNotes,
mutateProjects,
}}
>
{children}
</DataContext.Provider>
);
};

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -1,53 +0,0 @@
import { useState, useEffect } from 'react';
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
}
const useFetch = <T,>(url: string, options?: RequestInit): UseFetchResult<T> => {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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;

View file

@ -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<Area[]>('/api/areas?active=true', fetcher);
return {
areas: data || [],
isLoading: !error && !data,
isError: !!error,
mutate,
};
};
export default useFetchAreas;

View file

@ -1,15 +0,0 @@
import useFetch from './useFetch';
import { Note } from '../entities/Note';
const useFetchNotes = () => {
const { data, loading, error } = useFetch<Note[]>('/api/notes', {
credentials: 'include',
headers: {
Accept: 'application/json',
},
});
return { notes: data || [], loading, error };
};
export default useFetchNotes;

View file

@ -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<Project[]>([]);
const [taskStatusCounts, setTaskStatusCounts] = useState<any>();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(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;

View file

@ -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;

View file

@ -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;

View file

@ -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<Area>) => {
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<Area>) => {
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;

View file

@ -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<Note[]>('/api/notes', fetcher);
const createNote = useCallback(
async (noteData: Partial<Note>) => {
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<Note>) => {
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;

View file

@ -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<Project[]>([]);
const createProject = async (projectData: Partial<Project>): Promise<Project> => {
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<Project>) => {
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;

View file

@ -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<Tag>) => {
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<Tag>) => {
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;

View file

@ -1,91 +0,0 @@
import { useState } from 'react';
import { Task } from '../entities/Task';
const useManageTasks = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(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<Task>) => {
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<Task>) => {
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;

View file

@ -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<StoreState>((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 } })),
},
}));

View file

@ -0,0 +1,40 @@
import { Area } from "../entities/Area";
export const fetchAreas = async (): Promise<Area[]> => {
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<Area>): Promise<Area> => {
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<Area>): Promise<Area> => {
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<void> => {
const response = await fetch(`/api/areas/${areaId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete area.');
};

View file

@ -0,0 +1,40 @@
import { Note } from "../entities/Note";
export const fetchNotes = async (): Promise<Note[]> => {
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<Note> => {
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<Note> => {
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<void> => {
const response = await fetch(`/api/notes/${noteId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete note.');
};

View file

@ -0,0 +1,63 @@
import { Project } from "../entities/Project";
export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise<Project[]> => {
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<Project> => {
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<Project>): Promise<Project> => {
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<Project>): Promise<Project> => {
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<void> => {
const response = await fetch(`/api/project/${projectId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete project.');
};

View file

@ -0,0 +1,40 @@
import { Tag } from "../entities/Tag";
export const fetchTags = async (): Promise<Tag[]> => {
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<Tag> => {
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<Tag> => {
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<void> => {
const response = await fetch(`/api/tag/${tagId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete tag.');
};

View file

@ -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<Task> => {
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<Task> => {
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<void> => {
const response = await fetch(`/api/task/${taskId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete task.');
};

View file

@ -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

View file

@ -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)
.or(user.tasks.where(project_id: nil, status: statuses[:not_started]))
.where.not(id: excluded_task_ids)
.order(priority: :desc)
.limit(3)
.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')

View file

@ -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

View file

@ -5,6 +5,6 @@
</head>
<body>
<div id="root"></div>
<script src="/js/bundle.js"></script>
<script src="http://localhost:8080/js/bundle.js"></script>
</body>
</html>

View file

@ -0,0 +1,5 @@
class AddDueDateAtToProjects < ActiveRecord::Migration[7.1]
def change
add_column :projects, :due_date_at, :datetime
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_11_26_095028) do
ActiveRecord::Schema[7.1].define(version: 2025_02_24_162915) do
create_table "areas", force: :cascade do |t|
t.string "name"
t.integer "user_id", null: false
@ -48,6 +48,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_11_26_095028) do
t.boolean "active", default: false
t.boolean "pin_to_sidebar", default: false
t.integer "priority"
t.datetime "due_date_at"
t.index ["area_id"], name: "index_projects_on_area_id"
t.index ["user_id"], name: "index_projects_on_user_id"
end

37
package-lock.json generated
View file

@ -17,7 +17,8 @@
"react-router-dom": "^6.26.2",
"react-tagify": "^1.0.7",
"swr": "^2.2.5",
"tagify": "^0.1.1"
"tagify": "^0.1.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@babel/core": "^7.25.7",
@ -2545,7 +2546,7 @@
"version": "15.7.13",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
"integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
"dev": true
"devOptional": true
},
"node_modules/@types/qs": {
"version": "6.9.16",
@ -2563,7 +2564,7 @@
"version": "18.3.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
"integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@ -4170,7 +4171,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true
"devOptional": true
},
"node_modules/data-view-buffer": {
"version": "1.0.1",
@ -10297,6 +10298,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz",
"integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View file

@ -54,6 +54,7 @@
"react-router-dom": "^6.26.2",
"react-tagify": "^1.0.7",
"swr": "^2.2.5",
"tagify": "^0.1.1"
"tagify": "^0.1.1",
"zustand": "^5.0.3"
}
}

File diff suppressed because one or more lines are too long