Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,503 changes: 2,303 additions & 200 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@reduxjs/toolkit": "^2.9.1",
"@types/uuid": "^10.0.0",
"@uiw/react-md-editor": "^4.0.9",
"framer-motion": "^12.23.24",
"next": "16.0.0",
"react": "19.2.0",
Expand Down
46 changes: 46 additions & 0 deletions src/components/tasks/AddTaskForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,25 @@

import { useState } from "react";
import { useSelector } from "react-redux";
import dynamic from "next/dynamic";
import { RootState, PriorityLevel } from "@/lib/types";
import { useAddTaskMutation } from "@/lib/services/localApi";
import { LoadingSpinner } from "../ui/LoadingSpinner";
import { getTodayDateString } from "@/lib/utils/dateFormatting";
import { PRIORITY_LEVELS } from "@/lib/utils/priorityUtils";
import toast from "react-hot-toast";
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";

// Dynamically import MDEditor to avoid SSR issues
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });

export function AddTaskForm() {
const [title, setTitle] = useState("");
const [dueDate, setDueDate] = useState<string>(getTodayDateString());
const [priority, setPriority] = useState<PriorityLevel>(1); // Default to Jalapeño
const [description, setDescription] = useState("");
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [addTask, { isLoading }] = useAddTaskMutation();
const { currentUser } = useSelector((state: RootState) => state.user);

Expand All @@ -23,17 +31,20 @@
try {
await addTask({
title: title.trim(),
description: description.trim() || null,
completed: false,
priority,
userId: currentUser.id,
dueDate: dueDate || null,
}).unwrap();

setTitle("");
setDescription("");
setIsDescriptionExpanded(false);
setDueDate(getTodayDateString());
setPriority(1); // Reset to Jalapeño
toast.success("Task added successfully");
} catch (error) {

Check warning on line 47 in src/components/tasks/AddTaskForm.tsx

View workflow job for this annotation

GitHub Actions / ci

'error' is defined but never used
toast.error("Failed to add task");
}
};
Expand Down Expand Up @@ -84,6 +95,41 @@
{isLoading ? <LoadingSpinner /> : "Add Task"}
</button>
</div>

{/* Expandable Description Section */}
<div>
<button
type="button"
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
className="flex items-center gap-2 text-sm text-gray-400 hover:text-gray-300 transition-colors"
disabled={isLoading}
>
<svg
className={`w-4 h-4 transition-transform ${isDescriptionExpanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>{isDescriptionExpanded ? "Hide notes" : "Add notes (optional)"}</span>
{description && !isDescriptionExpanded && (
<span className="text-blue-400 text-xs">(has content)</span>
)}
</button>

{isDescriptionExpanded && (
<div className="mt-3" data-color-mode="dark">
<MDEditor
value={description}
onChange={(value) => setDescription(value || "")}
preview="edit"
height={150}
className="!bg-gray-700"
/>
</div>
)}
</div>
</div>
</form>
);
Expand Down
141 changes: 138 additions & 3 deletions src/components/tasks/TaskItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState } from "react";
import dynamic from "next/dynamic";
import { Task, PriorityLevel } from "@/lib/types";
import {
useUpdateTaskMutation,
Expand All @@ -11,6 +12,15 @@
import { PRIORITY_LEVELS, getPriorityStyles } from "@/lib/utils/priorityUtils";
import { highlightText } from "@/lib/utils/searchUtils";
import toast from "react-hot-toast";
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";

// Dynamically import MDEditor and Markdown to avoid SSR issues
const MDEditor = dynamic(() => import("@uiw/react-md-editor"), { ssr: false });
const MarkdownPreview = dynamic(
() => import("@uiw/react-md-editor").then((mod) => mod.default.Markdown),
{ ssr: false }
);

interface TaskItemProps {
task: Task;
Expand All @@ -24,6 +34,11 @@
const [isDeleting, setIsDeleting] = useState(false);
const [isEditingDate, setIsEditingDate] = useState(false);
const [editedDate, setEditedDate] = useState(task.dueDate || "");

// Description state
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [editedDescription, setEditedDescription] = useState(task.description || "");

const handleToggle = async () => {
try {
Expand Down Expand Up @@ -62,7 +77,7 @@
}).unwrap();
setIsEditingDate(false);
toast.success("Due date updated successfully");
} catch (error) {

Check warning on line 80 in src/components/tasks/TaskItem.tsx

View workflow job for this annotation

GitHub Actions / ci

'error' is defined but never used
toast.error("Failed to update due date");
} finally {
setIsUpdating(false);
Expand All @@ -74,6 +89,27 @@
setIsEditingDate(false);
};

const handleDescriptionUpdate = async () => {
try {
setIsUpdating(true);
await updateTask({
id: task.id,
description: editedDescription || null,
}).unwrap();
setIsEditingDescription(false);
toast.success("Description updated successfully");
} catch {
toast.error("Failed to update description");
} finally {
setIsUpdating(false);
}
};

const handleCancelDescriptionEdit = () => {
setEditedDescription(task.description || "");
setIsEditingDescription(false);
};

const isOverdue = (dateString: string | null): boolean => {
if (!dateString) return false;
const dueDate = new Date(dateString);
Expand Down Expand Up @@ -128,15 +164,111 @@
);
};

// Truncate description for preview (first ~100 chars)
const getTruncatedDescription = (text: string | null, maxLength = 100) => {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.substring(0, maxLength).trim() + "...";
};

const renderDescriptionSection = () => {
// Editing mode
if (isEditingDescription) {
return (
<div className="mt-3 border-t border-gray-700 pt-3" data-color-mode="dark">
<MDEditor
value={editedDescription}
onChange={(value) => setEditedDescription(value || "")}
preview="live"
height={200}
className="!bg-gray-700"
/>
<div className="mt-2 flex gap-2">
<button
onClick={handleDescriptionUpdate}
disabled={isUpdating}
className="px-3 py-1 bg-blue-600 text-gray-100 text-sm rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isUpdating ? "Saving..." : "Save"}
</button>
<button
onClick={handleCancelDescriptionEdit}
disabled={isUpdating}
className="px-3 py-1 text-gray-400 text-sm rounded-md hover:text-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
);
}

// Has description - show preview/expanded view
if (task.description) {
return (
<div className="mt-3 border-t border-gray-700 pt-3">
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
className="flex items-center gap-1 text-sm text-gray-400 hover:text-gray-300 transition-colors"
>
<svg
className={`w-4 h-4 transition-transform ${isDescriptionExpanded ? "rotate-90" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span>Notes</span>
</button>
<button
onClick={() => setIsEditingDescription(true)}
className="text-blue-400 hover:text-blue-300 text-sm underline"
disabled={isDeleting}
>
Edit
</button>
</div>

{isDescriptionExpanded ? (
// Full markdown preview
<div data-color-mode="dark" className="prose prose-invert prose-sm max-w-none">
<MarkdownPreview source={task.description || ""} />
</div>
) : (
// Truncated preview
<p className="text-sm text-gray-400 cursor-pointer" onClick={() => setIsDescriptionExpanded(true)}>
{getTruncatedDescription(task.description)}
</p>
)}
</div>
);
}

// No description - show add button
return (
<div className="mt-3">
<button
onClick={() => setIsEditingDescription(true)}
className="text-gray-500 hover:text-gray-400 text-sm underline"
disabled={isDeleting}
>
Add notes
</button>
</div>
);
};

return (
<div
className={`group p-5 bg-gray-800 rounded-lg border-2 border-gray-700 hover:border-gray-600 shadow-lg hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5 border-l-4 ${getPriorityStyles(
task.priority
)}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 flex-1">
<div className="relative">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
<div className="relative mt-1">
{isUpdating ? (
<div className="h-6 w-6 flex items-center justify-center">
<LoadingSpinner />
Expand Down Expand Up @@ -228,6 +360,9 @@
))}
</select>
</div>

{/* Description Section */}
{renderDescriptionSection()}
</div>
</div>
<button
Expand Down
2 changes: 1 addition & 1 deletion src/components/tasks/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function TaskList() {

if (searchQuery.trim()) {
filteredTasks = searchTasks(filteredTasks, searchQuery, {
fields: ["title"],
fields: ["title", "description"],
});
}

Expand Down
11 changes: 11 additions & 0 deletions src/lib/features/tasksSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ export const tasksSlice = createSlice({
task.updatedAt = new Date().toISOString();
}
},
updateTaskDescription: (
state,
action: PayloadAction<{ id: string; description: string | null }>
) => {
const task = state.items.find((t) => t.id === action.payload.id);
if (task) {
task.description = action.payload.description;
task.updatedAt = new Date().toISOString();
}
},
setTasks: (state, action: PayloadAction<Task[]>) => {
state.items = action.payload;
},
Expand All @@ -64,6 +74,7 @@ export const {
updateTaskPriority,
removeTask,
updateTaskDueDate,
updateTaskDescription,
setTasks,
setLoading,
setError,
Expand Down
41 changes: 33 additions & 8 deletions src/lib/services/localApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
setTasks,
toggleTask,
updateTaskDueDate,
updateTaskDescription,
updateTaskPriority,
} from "../features/tasksSlice";
import { saveState, loadState } from "../utils/localStorage";
Expand All @@ -26,27 +27,42 @@ export const localApi = createApi({
const state = loadState();
const tasks = state?.tasks?.items || [];

// Migrate old string-based priorities to numeric IDs
// Migrate old tasks: string-based priorities to numeric IDs, add description field
const migratedTasks = tasks.map(
(task: Task | (Omit<Task, "priority"> & { priority: string })) => {
let priority: PriorityLevel = typeof task.priority === "number" ? task.priority : 1;

// Migrate string priorities to numeric IDs
if (typeof task.priority === "string") {
let priorityId: PriorityLevel;
switch (task.priority) {
case "ghost-pepper":
priorityId = 0;
priority = 0;
break;
case "jalapeño":
priorityId = 1;
priority = 1;
break;
case "minnesotan":
priorityId = 2;
priority = 2;
break;
default:
priorityId = 1; // Default to Jalapeño
priority = 1; // Default to Jalapeño
}
return { ...task, priority: priorityId };
}
return task;

// Build migrated task with description field
const migratedTask: Task = {
id: task.id,
title: task.title,
description: "description" in task ? task.description : null,
completed: task.completed,
priority,
userId: task.userId,
dueDate: task.dueDate,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
};

return migratedTask;
}
);

Expand All @@ -65,6 +81,7 @@ export const localApi = createApi({
const newTask: Task = {
...task,
priority: task.priority ?? 1, // Default to Jalapeño (1)
description: task.description ?? null,
id: uuidv4(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
Expand Down Expand Up @@ -96,6 +113,14 @@ export const localApi = createApi({
updateTaskPriority({ id: update.id, priority: update.priority! })
);
}
if ("description" in update) {
dispatch(
updateTaskDescription({
id: update.id,
description: update.description ?? null,
})
);
}
const state = getState();
saveState(state);
const updatedTask = {
Expand Down
Loading
Loading