diff --git a/web/app/(pages)/tasks/today/page.tsx b/web/app/(pages)/tasks/today/page.tsx new file mode 100644 index 00000000..d2e4f8a0 --- /dev/null +++ b/web/app/(pages)/tasks/today/page.tsx @@ -0,0 +1,15 @@ +import { TaskRoute } from "@/components/routes/task/TaskRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function TodayTasksPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("TASK") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/app/(pages)/tasks/upcoming/page.tsx b/web/app/(pages)/tasks/upcoming/page.tsx new file mode 100644 index 00000000..2ab33656 --- /dev/null +++ b/web/app/(pages)/tasks/upcoming/page.tsx @@ -0,0 +1,15 @@ +import { TaskRoute } from "@/components/routes/task/TaskRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function UpcomingTasksPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("TASK") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/components/custom/sidebar/partial/task-section.tsx b/web/components/custom/sidebar/partial/task-section.tsx index 203100f7..ae90e9a2 100644 --- a/web/components/custom/sidebar/partial/task-section.tsx +++ b/web/components/custom/sidebar/partial/task-section.tsx @@ -1,17 +1,22 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { cn } from "@/lib/utils" -import { ListOfTasks } from "@/lib/schema/tasks" +import { ListOfTasks, Task } from "@/lib/schema/tasks" import { LaIcon } from "../../la-icon" import { useEffect, useState } from "react" import { useAuth, useUser } from "@clerk/nextjs" import { getFeatureFlag } from "@/app/actions" +import { isToday, isFuture } from "date-fns" +import { useAccount } from "@/lib/providers/jazz-provider" export const TaskSection: React.FC<{ pathname: string }> = ({ pathname }) => { - const me = { root: { tasks: [{ id: "1", title: "Test Task" }] } } + const { me } = useAccount({ root: { tasks: [] } }) - const taskCount = me?.root.tasks?.length || 0 - const isActive = pathname === "/tasks" + const taskCount = me?.root?.tasks?.length || 0 + const todayTasks = + me?.root?.tasks?.filter(task => task?.status !== "done" && task?.dueDate && isToday(task.dueDate)) || [] + const upcomingTasks = + me?.root?.tasks?.filter(task => task?.status !== "done" && task?.dueDate && isFuture(task.dueDate)) || [] const [isFetching, setIsFetching] = useState(false) const [isFeatureActive, setIsFeatureActive] = useState(false) @@ -53,7 +58,19 @@ export const TaskSection: React.FC<{ pathname: string }> = ({ pathname }) => { return (
- + + + {isFetching ? (
Fetching tasks...
) : ( @@ -64,11 +81,13 @@ export const TaskSection: React.FC<{ pathname: string }> = ({ pathname }) => { } interface TaskSectionHeaderProps { - taskCount: number + title: string + href: string + count: number isActive: boolean } -const TaskSectionHeader: React.FC = ({ taskCount, isActive }) => ( +const TaskSectionHeader: React.FC = ({ title, href, count, isActive }) => (
= ({ taskCount, isActi )} >

- Tasks - {taskCount > 0 && {taskCount}} + {title} + {count > 0 && {count}}

- //
- // - //
) interface ListProps { - tasks: ListOfTasks + tasks: (Task | null)[] | undefined } const List: React.FC = ({ tasks }) => { - const pathname = usePathname() - return ( -
- -
+
{tasks?.filter((task): task is Task => task !== null).map(task =>
{task.title}
)}
) } - -interface ListItemProps { - label: string - href: string - count: number - isActive: boolean -} - -const ListItem: React.FC = ({ label, href, count, isActive }) => ( -
-
- -
- -

{label}

-
- - {count > 0 && ( - {count} - )} -
-
-) diff --git a/web/components/routes/task/TaskForm.tsx b/web/components/routes/task/TaskForm.tsx index eb7090d5..98bdda9a 100644 --- a/web/components/routes/task/TaskForm.tsx +++ b/web/components/routes/task/TaskForm.tsx @@ -1,4 +1,126 @@ -"use client" +// import { useState, useEffect, useRef } from "react" +// import { motion, AnimatePresence } from "framer-motion" +// import { ListOfTasks, Task } from "@/lib/schema/tasks" +// import { Input } from "@/components/ui/input" +// import { Button } from "@/components/ui/button" +// import { useAccount } from "@/lib/providers/jazz-provider" +// import { LaIcon } from "@/components/custom/la-icon" +// import { Checkbox } from "@/components/ui/checkbox" +// import { format } from "date-fns" +// import { DatePicker } from "@/components/ui/date-picker" + +// interface TaskFormProps { +// filter?: "today" | "upcoming" +// } + +// export const TaskForm: React.FC = ({ filter }) => { +// const [title, setTitle] = useState("") +// const [dueDate, setDueDate] = useState(filter === "today" ? new Date() : undefined) +// const [inputVisible, setInputVisible] = useState(false) +// const { me } = useAccount({ root: {} }) +// const inputRef = useRef(null) + +// const handleSubmit = (e: React.FormEvent) => { +// e.preventDefault() +// if (title.trim() && (filter !== "upcoming" || dueDate)) { +// if (me?.root?.tasks === undefined) { +// if (!me) return +// me.root.tasks = ListOfTasks.create([], { owner: me }) +// } + +// const newTask = Task.create( +// { +// title, +// description: "", +// status: "todo", +// createdAt: new Date(), +// updatedAt: new Date(), +// dueDate: dueDate || null +// }, +// { owner: me._owner } +// ) +// me.root.tasks?.push(newTask) +// resetForm() +// } +// } + +// const resetForm = () => { +// setTitle("") +// setDueDate(filter === "today" ? new Date() : undefined) +// setInputVisible(false) +// } + +// const handleKeyDown = (e: React.KeyboardEvent) => { +// if (e.key === "Escape") { +// resetForm() +// } else if (e.key === "Backspace" && title.trim() === "") { +// resetForm() +// } +// } + +// useEffect(() => { +// if (inputVisible && inputRef.current) { +// inputRef.current.focus() +// } +// }, [inputVisible]) + +// const formattedDate = dueDate ? format(dueDate, "EEE, MMMM do, yyyy") : "Select a date" + +// return ( +//
+// +// {!inputVisible ? ( +// +// +// +// ) : ( +// +//
+// {}} /> +// setTitle(e.target.value)} +// onKeyDown={handleKeyDown} +// placeholder="Task title" +// /> +//
+//
+// {filter === "upcoming" && ( +// setDueDate(date)} /> +// )} +// {formattedDate} +//
+//
+// )} +//
+//
+// ) +// } + import { useState, useEffect, useRef } from "react" import { motion, AnimatePresence } from "framer-motion" import { ListOfTasks, Task } from "@/lib/schema/tasks" @@ -8,18 +130,22 @@ import { useAccount } from "@/lib/providers/jazz-provider" import { LaIcon } from "@/components/custom/la-icon" import { Checkbox } from "@/components/ui/checkbox" import { format } from "date-fns" +import { DatePicker } from "@/components/ui/date-picker" -interface TaskFormProps {} +interface TaskFormProps { + filter?: "today" | "upcoming" | undefined +} -export const TaskForm: React.FC = ({}) => { +export const TaskForm: React.FC = ({ filter }) => { const [title, setTitle] = useState("") + const [dueDate, setDueDate] = useState(filter === "today" ? new Date() : undefined) const [inputVisible, setInputVisible] = useState(false) const { me } = useAccount({ root: {} }) const inputRef = useRef(null) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - if (title.trim()) { + if (title.trim() && (filter === "today" || (filter === "upcoming" && dueDate))) { if (me?.root?.tasks === undefined) { if (!me) return me.root.tasks = ListOfTasks.create([], { owner: me }) @@ -31,7 +157,8 @@ export const TaskForm: React.FC = ({}) => { description: "", status: "todo", createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), + dueDate: dueDate || (filter === "today" ? new Date() : null) }, { owner: me._owner } ) @@ -42,6 +169,7 @@ export const TaskForm: React.FC = ({}) => { const resetForm = () => { setTitle("") + setDueDate(filter === "today" ? new Date() : undefined) setInputVisible(false) } @@ -59,55 +187,60 @@ export const TaskForm: React.FC = ({}) => { } }, [inputVisible]) - const formattedDate = format(new Date(), "EEE, MMMM do, yyyy") + const formattedDate = dueDate ? format(dueDate, "EEE, MMMM do, yyyy") : "Select a date" return (
- {!inputVisible ? ( - - - - ) : ( - -
- {}} /> - setTitle(e.target.value)} - onKeyDown={handleKeyDown} - // placeholder="Task title" - /> -
-
- {formattedDate} -
-
- )} + + + ) : ( + +
+ {}} /> + setTitle(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Task title" + /> +
+
+ {filter === "upcoming" && ( + setDueDate(date)} /> + )} + {formattedDate} +
+
+ ) + ) : null}
) diff --git a/web/components/routes/task/TaskItem.tsx b/web/components/routes/task/TaskItem.tsx index e4fd058c..ee20ef2e 100644 --- a/web/components/routes/task/TaskItem.tsx +++ b/web/components/routes/task/TaskItem.tsx @@ -1,24 +1,78 @@ import { Task } from "@/lib/schema/tasks" import { Checkbox } from "@/components/ui/checkbox" import { format } from "date-fns" +import { useState, useRef, useEffect } from "react" +import { Input } from "@/components/ui/input" interface TaskItemProps { task: Task onUpdateTask: (taskId: string, updates: Partial) => void + onDeleteTask: (taskId: string) => void } -export const TaskItem: React.FC = ({ task, onUpdateTask }) => { +export const TaskItem: React.FC = ({ task, onUpdateTask, onDeleteTask }) => { + const [isEditing, setIsEditing] = useState(false) + const [editedTitle, setEditedTitle] = useState(task.title) + const inputRef = useRef(null) + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus() + } + }, [isEditing]) + const statusChange = (checked: boolean) => { - onUpdateTask(task.id, { status: checked ? "done" : "todo" }) + if (checked) { + onDeleteTask(task.id) + } else { + onUpdateTask(task.id, { status: "todo" }) + } + } + + const clickTitle = () => { + setIsEditing(true) + } + + const titleChange = (e: React.ChangeEvent) => { + setEditedTitle(e.target.value) + } + + const titleBlur = () => { + setIsEditing(false) + if (editedTitle.trim() !== task.title) { + onUpdateTask(task.id, { title: editedTitle.trim() }) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + titleBlur() + } } const formattedDate = format(new Date(task.createdAt), "EEE, MMMM do, yyyy") return (
  • -
    +
    -

    {task.title}

    + {isEditing ? ( + + ) : ( +

    + {task.title} +

    + )}
    {formattedDate}
  • diff --git a/web/components/routes/task/TaskList.tsx b/web/components/routes/task/TaskList.tsx index 8478dae3..83d68470 100644 --- a/web/components/routes/task/TaskList.tsx +++ b/web/components/routes/task/TaskList.tsx @@ -1,20 +1,21 @@ import React from "react" -import { ListOfTasks, Task } from "@/lib/schema/tasks" +import { Task } from "@/lib/schema/tasks" import { TaskItem } from "./TaskItem" interface TaskListProps { - tasks?: ListOfTasks + tasks: Task[] onUpdateTask: (taskId: string, updates: Partial) => void + onDeleteTask: (taskId: string) => void } -export const TaskList: React.FC = ({ tasks, onUpdateTask }) => { +export const TaskList: React.FC = ({ tasks, onUpdateTask, onDeleteTask }) => { return (
      {tasks?.map( task => task?.id && (
    • - +
    • ) )} diff --git a/web/components/routes/task/TaskRoute.tsx b/web/components/routes/task/TaskRoute.tsx index a2efe5ec..9a3278fd 100644 --- a/web/components/routes/task/TaskRoute.tsx +++ b/web/components/routes/task/TaskRoute.tsx @@ -5,11 +5,28 @@ import { Task } from "@/lib/schema/tasks" import { TaskList } from "./TaskList" import { TaskForm } from "./TaskForm" import { LaIcon } from "@/components/custom/la-icon" +import { isToday, isFuture } from "date-fns" +import { useTaskActions } from "./new-task-actions" +import { ID } from "jazz-tools" -export const TaskRoute: React.FC = () => { +interface TaskRouteProps { + filter?: "today" | "upcoming" +} + +export const TaskRoute: React.FC = ({ filter }) => { const { me } = useAccount({ root: { tasks: [] } }) const tasks = me?.root.tasks - console.log(tasks, "tasks here") + const { deleteTask } = useTaskActions() + + const filteredTasks = tasks?.filter(task => { + if (!task) return false + if (filter === "today") { + return task.status !== "done" && task.dueDate && isToday(task.dueDate) + } else if (filter === "upcoming") { + return task.status !== "done" && task.dueDate && isFuture(task.dueDate) + } + return true + }) const updateTask = (taskId: string, updates: Partial) => { if (me?.root?.tasks) { @@ -20,14 +37,26 @@ export const TaskRoute: React.FC = () => { } } + const onDeleteTask = (taskId: string) => { + if (me) { + deleteTask(me, taskId as ID) + } + } + return (
      -

      Current Tasks

      +

      + {filter === "today" ? "Today's Tasks" : filter === "upcoming" ? "Upcoming Tasks" : "All Tasks"} +

      - - + + task !== null) || []} + onUpdateTask={updateTask} + onDeleteTask={onDeleteTask} + />
      ) } diff --git a/web/components/routes/task/TodayTaskRoute.tsx b/web/components/routes/task/TodayTaskRoute.tsx new file mode 100644 index 00000000..d2e4f8a0 --- /dev/null +++ b/web/components/routes/task/TodayTaskRoute.tsx @@ -0,0 +1,15 @@ +import { TaskRoute } from "@/components/routes/task/TaskRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function TodayTasksPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("TASK") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/components/routes/task/UpcomingTaskRoute.tsx b/web/components/routes/task/UpcomingTaskRoute.tsx new file mode 100644 index 00000000..2ab33656 --- /dev/null +++ b/web/components/routes/task/UpcomingTaskRoute.tsx @@ -0,0 +1,15 @@ +import { TaskRoute } from "@/components/routes/task/TaskRoute" +import { currentUser } from "@clerk/nextjs/server" +import { notFound } from "next/navigation" +import { get } from "ronin" + +export default async function UpcomingTasksPage() { + const user = await currentUser() + const flag = await get.featureFlag.with.name("TASK") + + if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { + notFound() + } + + return +} diff --git a/web/components/routes/task/new-task-actions.ts b/web/components/routes/task/new-task-actions.ts new file mode 100644 index 00000000..484a8dbb --- /dev/null +++ b/web/components/routes/task/new-task-actions.ts @@ -0,0 +1,62 @@ +import { useCallback } from "react" +import { toast } from "sonner" +import { LaAccount } from "@/lib/schema" +import { Task, ListOfTasks } from "@/lib/schema/tasks" +import { ID } from "jazz-tools" + +export const useTaskActions = () => { + const newTask = useCallback((me: LaAccount): Task | null => { + if (!me.root) { + console.error("User root is not initialized") + return null + } + + if (!me.root.tasks) { + me.root.tasks = ListOfTasks.create([], { owner: me }) + } + + const newTask = Task.create( + { + title: "", + description: "", + status: "todo", + createdAt: new Date(), + updatedAt: new Date() + }, + { owner: me._owner } + ) + + me.root.tasks.push(newTask) + return newTask + }, []) + + const deleteTask = useCallback((me: LaAccount, taskId: ID): void => { + if (!me.root?.tasks) return + + const index = me.root.tasks.findIndex(item => item?.id === taskId) + if (index === -1) { + toast.error("Task not found") + return + } + + const task = me.root.tasks[index] + if (!task) { + toast.error("Task data is invalid") + return + } + + try { + me.root.tasks.splice(index, 1) + + toast.success("Task deleted", { + position: "bottom-right", + description: `${task.title} has been deleted.` + }) + } catch (error) { + console.error("Failed to delete task", error) + toast.error("Failed to delete task") + } + }, []) + + return { newTask, deleteTask } +} diff --git a/web/components/ui/date-picker.tsx b/web/components/ui/date-picker.tsx new file mode 100644 index 00000000..6885577d --- /dev/null +++ b/web/components/ui/date-picker.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import { format } from "date-fns" +import { Calendar as CalendarIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" + +interface DatePickerProps { + date: Date | undefined + onDateChange: (date: Date | undefined) => void + className?: string +} + +export function DatePicker({ date, onDateChange, className }: DatePickerProps) { + return ( + + + + + + + + + ) +} diff --git a/web/lib/schema/tasks.ts b/web/lib/schema/tasks.ts index 71cf643f..e5cb9c2a 100644 --- a/web/lib/schema/tasks.ts +++ b/web/lib/schema/tasks.ts @@ -6,6 +6,7 @@ export class Task extends CoMap { status = co.literal("todo", "in_progress", "done") createdAt = co.encoded(Encoders.Date) updatedAt = co.encoded(Encoders.Date) + dueDate = co.optional.encoded(Encoders.Date) } export class ListOfTasks extends CoList.Of(co.ref(Task)) {}