Add today page

This commit is contained in:
Chris Veleris 2024-11-18 21:47:04 +02:00
parent 0cd010b4b1
commit 2f46b25eba
14 changed files with 859 additions and 58 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 }); // Navigate to /today instead of /tasks?type=today
}
}, [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

@ -18,7 +18,7 @@ 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' },

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

@ -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,188 @@
import React from "react";
import { format } from "date-fns";
import {
ClipboardDocumentListIcon,
ClockIcon,
ArrowPathIcon,
CalendarDaysIcon, // Import the icon for due tasks
} 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 = () => {
// Fetch tasks and metrics
const {
tasks,
metrics,
isLoading: loadingTasks,
isError: errorTasks,
} = useFetchTasks({
type: "today",
});
// Fetch projects
const {
projects,
isLoading: loadingProjects,
isError: errorProjects,
} = useFetchProjects();
// Task management functions
const { updateTask, deleteTask } = useManageTasks();
// Handle task updates
const handleTaskUpdate = (updatedTask: Task): void => {
if (updatedTask.id === undefined) {
console.error("Error updating task: Task ID is undefined.");
return;
}
updateTask(updatedTask.id, updatedTask)
.then(() => {
// Optionally, refetch tasks or update local state
})
.catch((error) => {
console.error("Error updating task:", error);
});
};
// Handle task deletion
const handleTaskDelete = (taskId: number): void => {
deleteTask(taskId)
.then(() => {
// Optionally, refetch tasks or update local state
})
.catch((error) => {
console.error("Error deleting task:", error);
});
};
// Handle loading and error states
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

@ -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,86 @@
import { useState, useEffect } from 'react';
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 useFetchTasks = (options?: UseFetchTasksOptions): UseFetchTasksResult => {
const [tasks, setTasks] = useState<Task[]>([]);
const [metrics, setMetrics] = useState<Metrics>(initialMetrics);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isError, setIsError] = useState<boolean>(false);
const fetchTasks = async () => {
setIsLoading(true);
setIsError(false);
try {
let url = '/api/tasks';
const params = new URLSearchParams();
if (options?.type) {
params.append('type', options.type);
}
if (options?.tag) {
params.append('tag', options.tag);
}
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();
setTasks(data.tasks || []);
setMetrics(data.metrics || initialMetrics);
} else {
throw new Error('Failed to fetch tasks.');
}
} catch (error) {
console.error('Error fetching tasks:', error);
setIsError(true);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTasks();
}, [options?.type, options?.tag]);
return { tasks, metrics, isLoading, isError, mutate: fetchTasks };
};
export default useFetchTasks;

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,79 @@ 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
end

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,7 +54,7 @@ module Sinatra
task_attributes = {
name: task_data['name'],
priority: task_data['priority'] || 'medium',
priority: task_data['priority'],
due_date: task_data['due_date'],
status: task_data['status'] || Task.statuses[:not_started],
note: task_data['note'],

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