Merge pull request #38 from chrisvel/today-projects

Add today page
This commit is contained in:
Chris 2024-11-22 20:59:22 +02:00 committed by GitHub
commit 0c32c87ba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1028 additions and 140 deletions

View file

@ -21,6 +21,7 @@ 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";
const App: React.FC = () => {
const [currentUser, setCurrentUser] = useState<User | null>(null);
@ -85,7 +86,7 @@ const App: React.FC = () => {
useEffect(() => {
if (currentUser && location.pathname === "/") {
navigate("/tasks?type=today", { replace: true });
navigate("/today", { replace: true });
}
}, [currentUser, location.pathname, navigate]);
@ -104,12 +105,13 @@ const App: React.FC = () => {
{currentUser ? (
<Layout
currentUser={currentUser}
setCurrentUser={setCurrentUser}
setCurrentUser={setCurrentUser}
isDarkMode={isDarkMode}
toggleDarkMode={toggleDarkMode}
>
<Routes>
<Route path="/" element={<Navigate to="/tasks" replace />} />
<Route path="/" element={<Navigate to="/today" replace />} />
<Route path="/today" element={<TasksToday />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/projects" element={<Projects />} />
<Route path="/project/:id" element={<ProjectDetails />} />
@ -119,7 +121,10 @@ const App: React.FC = () => {
<Route path="/tag/:id" element={<TagDetails />} />
<Route path="/notes" element={<Notes />} />
<Route path="/note/:id" element={<NoteDetails />} />
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} />} />
<Route
path="/profile"
element={<ProfileSettings currentUser={currentUser} />}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</Layout>

View file

@ -24,7 +24,7 @@ const Login: React.FC = () => {
if (response.ok) {
console.log('Login successful:', data);
navigate('/tasks?type=today&order_by=due_date%3Aasc');
navigate('/today');
} else {
setError(data.errors[0] || 'Login failed. Please try again.');
}

View file

@ -1,10 +1,11 @@
import React, { useEffect, useState } from "react";
import { useParams, useLocation, useNavigate, Link } from "react-router-dom";
import { useParams, useNavigate, Link } from "react-router-dom";
import {
PencilSquareIcon,
TrashIcon,
FolderIcon,
Squares2X2Icon,
} from "@heroicons/react/24/solid";
} from "@heroicons/react/24/outline";
import TaskList from "../Task/TaskList";
import ProjectModal from "../Project/ProjectModal";
import ConfirmDialog from "../Shared/ConfirmDialog";
@ -16,7 +17,6 @@ import { Task } from "../../entities/Task";
const ProjectDetails: React.FC = () => {
const { updateTask, deleteTask, updateProject, deleteProject } = useDataContext();
const { id } = useParams<{ id: string }>();
const location = useLocation();
const navigate = useNavigate();
const { areas } = useDataContext();
@ -28,9 +28,7 @@ const ProjectDetails: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const { title: stateTitle, icon: stateIcon } = location.state || {};
const projectTitle = stateTitle || project?.name || "Project";
const projectIcon = stateIcon;
const projectTitle = project?.name || "Project";
const [isCompletedOpen, setIsCompletedOpen] = useState(false);
@ -129,8 +127,8 @@ const ProjectDetails: React.FC = () => {
if (!updatedProject) return;
try {
await updateProject(updatedProject.id!, updatedProject);
setProject(updatedProject);
const savedProject = await updateProject(updatedProject.id!, updatedProject);
setProject(savedProject);
setIsModalOpen(false);
} catch (err) {
console.error("Error saving project:", err);
@ -179,7 +177,7 @@ const ProjectDetails: React.FC = () => {
{/* Project Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<i className={`${projectIcon} text-xl mr-2`}></i>
<FolderIcon className="h-6 w-6 text-gray-500 mr-2" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100">
{projectTitle}
</h2>

View file

@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { Area } from '../../entities/Area';
import { Project } from '../../entities/Project';
import ConfirmDialog from '../Shared/ConfirmDialog';
import { useToast } from '../Shared/ToastContext';
import { XMarkIcon } from '@heroicons/react/24/outline';
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Area } from "../../entities/Area";
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";
interface ProjectModalProps {
isOpen: boolean;
@ -24,13 +25,18 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}) => {
const [formData, setFormData] = useState<Project>(
project || {
name: '',
description: '',
name: "",
description: "",
area_id: null,
active: true,
tags: [],
}
);
const [tags, setTags] = useState<string[]>(project?.tags?.map(tag => tag.name) || []);
const { tags: availableTags, isLoading: isTagsLoading, isError: isTagsError } = useFetchTags();
const modalRef = useRef<HTMLDivElement>(null);
const [isClosing, setIsClosing] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
@ -39,14 +45,20 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
useEffect(() => {
if (project) {
setFormData(project);
setFormData({
...project,
tags: project.tags || [],
});
setTags(project.tags?.map(tag => tag.name) || []);
} else {
setFormData({
name: '',
description: '',
name: "",
description: "",
area_id: null,
active: true,
tags: [],
});
setTags([]);
}
}, [project]);
@ -61,24 +73,24 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
handleClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("keydown", handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
@ -88,16 +100,17 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
>
) => {
const target = e.target;
const { name, type } = target;
const { name, type, value } = target;
if (type === 'checkbox') {
const checked = (target as HTMLInputElement).checked;
setFormData((prev) => ({
...prev,
[name]: checked,
}));
if (type === "checkbox") {
if (target instanceof HTMLInputElement) {
const checked = target.checked;
setFormData((prev) => ({
...prev,
[name]: checked,
}));
}
} else {
const value = target.value;
setFormData((prev) => ({
...prev,
[name]: value,
@ -105,10 +118,20 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
}
};
const handleTagsChange = useCallback((newTags: string[]) => {
setTags(newTags);
setFormData((prev) => ({
...prev,
tags: newTags.map((name) => ({ name })),
}));
}, []);
const handleSubmit = () => {
onSave(formData);
onSave({ ...formData, tags: tags.map(name => ({ name })) });
showSuccessToast(
project ? 'Project updated successfully!' : 'Project created successfully!'
project
? "Project updated successfully!"
: "Project created successfully!"
);
handleClose();
};
@ -120,7 +143,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const handleDeleteConfirm = () => {
if (project && project.id && onDelete) {
onDelete(project.id);
showSuccessToast('Project deleted successfully!');
showSuccessToast("Project deleted successfully!");
setShowConfirmDialog(false);
handleClose();
}
@ -131,29 +154,53 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
setTimeout(() => {
onClose();
setIsClosing(false);
}, 300);
}, 300);
};
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'
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 ${
isClosing ? 'scale-95' : 'scale-100'
isClosing ? "scale-95" : "scale-100"
} h-screen sm:h-auto flex flex-col`}
style={{
maxHeight: 'calc(100vh - 4rem)',
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">
@ -178,13 +225,27 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
id="projectDescription"
name="description"
rows={4}
value={formData.description || ''}
value={formData.description || ""}
onChange={handleChange}
className="block w-full rounded-md shadow-sm p-3 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"
placeholder="Enter project description (optional)"
></textarea>
</div>
{/* Tags */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Tags
</label>
<div className="w-full">
<TagInput
onTagsChange={handleTagsChange}
initialTags={tags}
availableTags={availableTags}
/>
</div>
</div>
{/* Area */}
<div className="pb-3">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
@ -193,7 +254,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
<select
id="projectArea"
name="area_id"
value={formData.area_id || ''}
value={formData.area_id || ""}
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"
>
@ -248,7 +309,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
onClick={handleSubmit}
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"
>
{project ? 'Update Project' : 'Create Project'}
{project ? "Update Project" : "Create Project"}
</button>
</div>
</fieldset>
@ -256,6 +317,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
</div>
</div>
{/* Confirmation Dialog for Deletion */}
{showConfirmDialog && (
<ConfirmDialog
title="Delete Project"

View file

@ -18,12 +18,12 @@ interface SidebarNavProps {
}
const navLinks = [
{ path: '/tasks?type=today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
{ path: '/today', title: 'Today', icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
{ path: '/tasks?type=upcoming', title: 'Upcoming', icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
{ path: '/tasks?type=next', title: 'Next Actions', icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
{ path: '/tasks?type=inbox', title: 'Inbox', icon: <InboxIcon className="h-5 w-5" />, query: 'type=inbox' },
{ path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
{ path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
// { path: '/tasks?type=someday', title: 'Someday', icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
// { path: '/tasks?type=waiting', title: 'Waiting for', icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
{ path: '/tasks?status=done', title: 'Completed', icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
{ path: '/tasks', title: 'All Tasks', icon: <ListBulletIcon className="h-5 w-5" /> },
];

View file

@ -41,7 +41,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)}
</div>
</div>
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-1">
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
{/* Tags without onTagRemove prop */}
<TaskTags tags={task.tags || []} />
{task.due_date && <TaskDueDate dueDate={task.due_date} />}

View file

@ -255,7 +255,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
key={project.id}
type="button"
onClick={() => handleProjectSelection(project)}
className="block w-full text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
className="block w-full text-gray-500 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
>
{project.name}
</button>

View file

@ -17,11 +17,11 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
};
return (
<div className={`flex flex-wrap gap-1 ${className}`}>
<div className={`flex flex-wrap gap-2 ${className}`}>
{tags.map((tag, index) => (
<div
key={tag.id || index}
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium mr-2 px-2.5 py-1 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
className="flex items-center bg-gray-200 text-gray-800 text-xs font-medium px-2 py-1.5 rounded-md dark:bg-gray-700 dark:text-gray-200 cursor-pointer"
>
<button
type="button"

View file

@ -0,0 +1,183 @@
import React from "react";
import { format } from "date-fns";
import {
ClipboardDocumentListIcon,
ClockIcon,
ArrowPathIcon,
CalendarDaysIcon,
} from "@heroicons/react/24/outline";
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 TaskList from "./TaskList";
const TasksToday: React.FC = () => {
const {
tasks,
metrics,
isLoading: loadingTasks,
isError: errorTasks,
mutate: mutateTasks,
} = useFetchTasks({
type: "today",
});
const {
projects,
isLoading: loadingProjects,
isError: errorProjects,
} = useFetchProjects();
const { updateTask, deleteTask } = useManageTasks();
const handleTaskUpdate = (updatedTask: Task): void => {
if (updatedTask.id === undefined) {
console.error("Error updating task: Task ID is undefined.");
return;
}
updateTask(updatedTask.id, updatedTask)
.then(() => {
mutateTasks();
})
.catch((error) => {
console.error("Error updating task:", error);
});
};
const handleTaskDelete = (taskId: number): void => {
deleteTask(taskId)
.then(() => {
mutateTasks();
})
.catch((error) => {
console.error("Error deleting task:", error);
});
};
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>;
}
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
</h2>
<span className="ml-4 text-gray-500">
{format(new Date(), "EEEE, MMMM d, yyyy")}
</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}
</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>
<p className="text-gray-500 dark:text-gray-400">In Progress</p>
<p className="text-2xl font-semibold">
{metrics.tasks_in_progress_count}
</p>
</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>
<p className="text-gray-500 dark:text-gray-400">Due Today</p>
<p className="text-2xl font-semibold">
{metrics.tasks_due_today.length}
</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>
<TaskList
tasks={metrics.tasks_due_today}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
/>
</>
)}
{/* Tasks In Progress */}
{metrics.tasks_in_progress.length > 0 && (
<>
<h3 className="text-xl font-medium mt-6 mb-2">Tasks In Progress</h3>
<TaskList
tasks={metrics.tasks_in_progress}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
/>
</>
)}
{/* Suggested Tasks */}
{metrics.suggested_tasks.length > 0 && (
<>
<h3 className="text-xl font-medium mt-6 mb-2">Suggested Tasks</h3>
<TaskList
tasks={metrics.suggested_tasks}
onTaskUpdate={handleTaskUpdate}
onTaskDelete={handleTaskDelete}
projects={projects}
/>
</>
)}
{/* Fallback Message */}
{tasks.length === 0 && (
<p className="text-gray-500 text-center mt-4">
No tasks available for today.
</p>
)}
</div>
</div>
);
};
export default TasksToday;

View file

@ -77,7 +77,7 @@ const Tasks: React.FC = () => {
if (tasksResponse.ok) {
const tasksData = await tasksResponse.json();
setTasks(tasksData || []);
setTasks(tasksData.tasks || []);
} else {
throw new Error("Failed to fetch tasks.");
}

View file

@ -24,7 +24,7 @@ interface DataContextProps {
updateArea: (areaId: number, areaData: any) => Promise<void>;
deleteArea: (areaId: number) => Promise<void>;
createProject: (projectData: any) => Promise<Project>;
updateProject: (projectId: number, projectData: any) => Promise<void>;
updateProject: (projectId: number, projectData: any) => Promise<Project>;
deleteProject: (projectId: number) => Promise<void>;
createTag: (tagData: any) => Promise<void>;
updateTag: (tagId: number, tagData: any) => Promise<void>;

View file

@ -1,4 +1,5 @@
import { Area } from "./Area";
import { Tag } from "./Tag";
export interface Project {
id?: number;
@ -7,5 +8,6 @@ export interface Project {
active: boolean;
pin_to_sidebar?: boolean;
area?: Area;
area_id?: number | null;
area_id?: number | null;
tags?: Tag[];
}

View file

@ -9,6 +9,7 @@ export interface Task {
note?: string;
tags?: Tag[];
project_id?: number;
created_at?: string;
}
export type StatusType = 'not_started' | 'in_progress' | 'done' | 'archived';

View file

@ -0,0 +1,69 @@
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

@ -41,6 +41,7 @@ const useManageProjects = () => {
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;

View file

@ -3,6 +3,7 @@ class Project < ActiveRecord::Base
belongs_to :area, optional: true
has_many :tasks, dependent: :destroy
has_many :notes, dependent: :destroy
has_and_belongs_to_many :tags
scope :with_incomplete_tasks, -> { joins(:tasks).where.not(tasks: { status: Task.statuses[:done] }).distinct }
scope :with_complete_tasks, -> { joins(:tasks).where(tasks: { status: Task.statuses[:done] }).distinct }

View file

@ -2,6 +2,7 @@ class Tag < ActiveRecord::Base
belongs_to :user
has_and_belongs_to_many :tasks
has_and_belongs_to_many :notes
has_and_belongs_to_many :projects
validates :name, presence: true, uniqueness: { scope: :user_id }
end

View file

@ -6,9 +6,10 @@ class Task < ActiveRecord::Base
enum priority: { low: 0, medium: 1, high: 2 }
enum status: { not_started: 0, in_progress: 1, done: 2, archived: 3, waiting: 4 }
# Existing scopes
scope :complete, -> { where(status: statuses[:done]) }
scope :incomplete, -> { where.not(status: statuses[:done]) }
scope :due_today, -> { incomplete.where('due_date <= ?', Date.today.end_of_day) }
scope :due_today, -> { incomplete.where('DATE(due_date) < ?', Date.today) }
scope :upcoming, -> { incomplete.where('due_date BETWEEN ? AND ?', Date.today, Date.today + 7.days) }
scope :someday, -> { incomplete.where(due_date: nil) }
scope :next_actions, -> { incomplete.where(due_date: nil, project_id: nil) }
@ -26,5 +27,85 @@ class Task < ActiveRecord::Base
scope :by_status, ->(status) { where(status: statuses[status]) }
scope :by_priority, ->(priority) { where(priority: priorities[priority]) }
scope :order_by_priority, -> { order(priority: :desc) }
validates :name, presence: true, uniqueness: { scope: :user_id }
# New class method to filter tasks based on params
def self.filter_by_params(params, user)
tasks = user.tasks.includes(:project, :tags)
tasks = case params[:type]
when 'today'
tasks
when 'upcoming'
tasks.upcoming
when 'next'
tasks.next_actions
when 'inbox'
tasks.inbox
when 'someday'
tasks.someday
when 'waiting'
tasks.waiting_for
else
params[:status] == 'done' ? tasks.complete : tasks.incomplete
end
tasks = tasks.with_tag(params[:tag]) if params[:tag]
tasks = tasks.apply_ordering(params[:order_by]) if params[:order_by]
tasks.left_joins(:tags).distinct
end
scope :apply_ordering, lambda { |order_by|
order_column, order_direction = order_by.split(':')
order_direction ||= 'asc'
order_direction = order_direction.downcase == 'desc' ? :desc : :asc
allowed_columns = %w[created_at updated_at name priority status due_date]
raise ArgumentError, 'Invalid order column specified.' unless allowed_columns.include?(order_column)
if order_column == 'due_date'
ordered_by_due_date(order_direction)
else
order("tasks.#{order_column} #{order_direction}")
end
}
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_count = tasks_in_progress.count
tasks_due_today = user.tasks.due_today
# Suggested tasks
excluded_task_ids = tasks_in_progress.pluck(:id) + tasks_due_today.pluck(:id)
suggested_tasks = user.tasks.incomplete
.where(status: statuses[:not_started])
.where.not(id: excluded_task_ids)
.order_by_priority
.limit(5)
{
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: suggested_tasks
}
end
def as_json(options = {})
super(options).merge(
'due_date' => due_date&.strftime('%Y-%m-%d')
)
end
end

View file

@ -3,6 +3,18 @@ require 'sinatra/namespace'
class Sinatra::Application
register Sinatra::Namespace
def update_project_tags(project, tags_data)
return if tags_data.nil?
tag_names = tags_data.map { |tag| tag['name'] }.compact.reject(&:empty?).uniq
existing_tags = Tag.where(user: current_user, name: tag_names)
new_tags = tag_names - existing_tags.pluck(:name)
created_tags = new_tags.map { |name| Tag.create(name: name, user: current_user) }
project.tags = (existing_tags + created_tags).uniq
end
namespace '/api' do
before do
content_type :json
@ -18,6 +30,7 @@ class Sinatra::Application
area_id_param = params[:area_id]
projects = current_user.projects
.includes(:tags)
.left_joins(:tasks, :area)
.distinct
.order('projects.name ASC')
@ -32,18 +45,18 @@ class Sinatra::Application
grouped_projects = projects.group_by(&:area)
{
projects: projects.as_json(include: { tasks: {}, area: { only: :name } }),
projects: projects.as_json(include: { tasks: {}, area: { only: :name }, tags: { only: %i[id name] } }),
task_status_counts: task_status_counts,
grouped_projects: grouped_projects.as_json(include: { area: { only: :name } })
}.to_json
end
get '/project/:id' do
project = current_user.projects.includes(:tasks).find_by(id: params[:id])
project = current_user.projects.includes(:tasks, :tags).find_by(id: params[:id])
halt 404, { error: 'Project not found' }.to_json unless project
project.as_json(include: { tasks: {}, area: { only: %i[id name] } }).to_json
project.as_json(include: { tasks: {}, area: { only: %i[id name] }, tags: { only: %i[id name] } }).to_json
end
post '/project' do
@ -63,8 +76,9 @@ class Sinatra::Application
)
if project.save
update_project_tags(project, project_data['tags'])
status 201
project.as_json.to_json
project.as_json(include: { tags: { only: %i[id name] } }).to_json
else
status 400
{ error: 'There was a problem creating the project.', details: project.errors.full_messages }.to_json
@ -92,7 +106,8 @@ class Sinatra::Application
)
if project.save
project.as_json.to_json
update_project_tags(project, project_data['tags'])
project.as_json(include: { tags: { only: %i[id name] } }).to_json
else
status 400
{ error: 'There was a problem updating the project.', details: project.errors.full_messages }.to_json

View file

@ -15,47 +15,31 @@ module Sinatra
get '/api/tasks' do
content_type :json
@tasks = current_user.tasks.includes(:project, :tags)
@tasks = case params[:type]
when 'today'
@tasks.due_today
when 'upcoming'
@tasks.upcoming
when 'next'
@tasks.next_actions
when 'inbox'
@tasks.inbox
when 'someday'
@tasks.someday
when 'waiting'
@tasks.waiting_for
else
params[:status] == 'done' ? @tasks.complete : @tasks.incomplete
end
@tasks = @tasks.with_tag(params[:tag]) if params[:tag]
if params[:order_by]
order_column, order_direction = params[:order_by].split(':')
order_direction ||= 'asc'
order_direction = order_direction.downcase == 'desc' ? :desc : :asc
allowed_columns = %w[created_at updated_at name priority status due_date]
if allowed_columns.include?(order_column)
@tasks = if order_column == 'due_date'
@tasks.ordered_by_due_date(order_direction)
else
@tasks.order("tasks.#{order_column} #{order_direction}")
end
else
halt 400, { error: 'Invalid order column specified.' }.to_json
end
begin
tasks = Task.filter_by_params(params, current_user)
rescue ArgumentError => e
halt 400, { error: e.message }.to_json
end
@tasks = @tasks.left_joins(:tags).distinct
metrics = Task.compute_metrics(current_user)
@tasks.to_json(include: { tags: { only: %i[id name] }, project: { only: :name } })
# Prepare the response
response = {
tasks: tasks.as_json(include: { tags: { only: %i[id name] }, project: { only: :name } }),
metrics: {
total_open_tasks: metrics[:total_open_tasks],
tasks_pending_over_month: metrics[:tasks_pending_over_month],
tasks_in_progress_count: metrics[:tasks_in_progress_count],
tasks_in_progress: metrics[:tasks_in_progress].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } }),
tasks_due_today: metrics[:tasks_due_today].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } }),
suggested_tasks: metrics[:suggested_tasks].as_json(include: { tags: { only: %i[id name] },
project: { only: :name } })
}
}
response.to_json
end
post '/api/task' do
@ -70,8 +54,8 @@ module Sinatra
task_attributes = {
name: task_data['name'],
priority: task_data['priority'] || 'medium',
due_date: task_data['due_date'],
priority: task_data['priority'],
due_date: task_data['due_date'].presence,
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
user_id: current_user.id
@ -117,7 +101,7 @@ module Sinatra
priority: task_data['priority'],
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],
due_date: task_data['due_date']
due_date: task_data['due_date'].presence
}
if task_data['project_id'] && !task_data['project_id'].to_s.strip.empty?

View file

@ -0,0 +1,14 @@
class CreateProjectsTags < ActiveRecord::Migration[7.1]
def change
create_table :projects_tags, id: false do |t|
t.integer :project_id, null: false
t.integer :tag_id, null: false
end
add_index :projects_tags, :project_id
add_index :projects_tags, :tag_id
add_foreign_key :projects_tags, :projects
add_foreign_key :projects_tags, :tags
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_10_16_105827) do
ActiveRecord::Schema[7.1].define(version: 2024_11_21_113756) do
create_table "areas", force: :cascade do |t|
t.string "name"
t.integer "user_id", null: false
@ -51,6 +51,13 @@ ActiveRecord::Schema[7.1].define(version: 2024_10_16_105827) do
t.index ["user_id"], name: "index_projects_on_user_id"
end
create_table "projects_tags", id: false, force: :cascade do |t|
t.integer "project_id", null: false
t.integer "tag_id", null: false
t.index ["project_id"], name: "index_projects_tags_on_project_id"
t.index ["tag_id"], name: "index_projects_tags_on_tag_id"
end
create_table "tags", force: :cascade do |t|
t.string "name"
t.integer "user_id", null: false
@ -99,6 +106,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_10_16_105827) do
add_foreign_key "notes", "users", on_delete: :cascade
add_foreign_key "projects", "areas", on_delete: :cascade
add_foreign_key "projects", "users"
add_foreign_key "projects_tags", "projects"
add_foreign_key "projects_tags", "tags"
add_foreign_key "tags", "users", on_delete: :cascade
add_foreign_key "tags_tasks", "tags"
add_foreign_key "tags_tasks", "tasks"

10
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@heroicons/react": "^2.1.5",
"@yaireo/tagify": "^4.31.3",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@ -4222,6 +4223,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",

View file

@ -48,6 +48,7 @@
"dependencies": {
"@heroicons/react": "^2.1.5",
"@yaireo/tagify": "^4.31.3",
"date-fns": "^4.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",

File diff suppressed because one or more lines are too long