From 51c22158c1855e8f3f655a7afd5dd93291efd4b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 15:26:28 +0000 Subject: [PATCH] Add Kanban task planning board and fix stale day detection Introduces a full-featured Kanban board at /tasks for planning work ahead of time with four status columns (To Do, In Progress, Blocked, Done). Planned tasks are a separate entity from time-tracked tasks, enabling cross-day carry-over naturally. Key additions: - PlannedTask entity with status, priority, and linkedTaskId fields - Dual storage: localStorage (guest) and Supabase (authenticated) - "Pull to Day" action starts timing a planned task immediately, marking it in_progress and linking it to the active work day - Stale day fix: endDay() now accepts an optional end time; a StaleDayDialog auto-opens when an unfinished day from a previous date is detected, letting the user set the correct end time before the day records with accurate durations https://claude.ai/code/session_0141FfeR698mBiwxojcza868 --- src/components/KanbanBoard.tsx | 53 ++++ src/components/KanbanColumn.tsx | 88 +++++++ src/components/PlannedTaskCard.tsx | 240 ++++++++++++++++++ src/components/PlannedTaskDialog.tsx | 225 ++++++++++++++++ src/components/StaleDayDialog.tsx | 82 ++++++ src/contexts/TimeTrackingContext.tsx | 131 +++++++++- src/pages/Index.tsx | 5 +- src/pages/TaskList.tsx | 135 +--------- src/services/dataService.ts | 6 +- src/services/localStorageService/constants.ts | 3 +- src/services/localStorageService/index.ts | 11 +- .../localStorageService/plannedTasks.ts | 23 ++ src/services/supabaseService.ts | 109 +++++++- .../migrations/20260525_planned_tasks.sql | 40 +++ 14 files changed, 1004 insertions(+), 147 deletions(-) create mode 100644 src/components/KanbanBoard.tsx create mode 100644 src/components/KanbanColumn.tsx create mode 100644 src/components/PlannedTaskCard.tsx create mode 100644 src/components/PlannedTaskDialog.tsx create mode 100644 src/components/StaleDayDialog.tsx create mode 100644 src/services/localStorageService/plannedTasks.ts create mode 100644 supabase/migrations/20260525_planned_tasks.sql diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..7650d39 --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; +import { PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { KanbanColumn } from "@/components/KanbanColumn"; +import { PlannedTaskDialog } from "@/components/PlannedTaskDialog"; +import { Button } from "@/components/ui/button"; +import { Plus } from "lucide-react"; + +const COLUMNS: { status: PlannedTaskStatus; title: string }[] = [ + { status: "todo", title: "To Do" }, + { status: "in_progress", title: "In Progress" }, + { status: "blocked", title: "Blocked" }, + { status: "done", title: "Done" }, +]; + +export const KanbanBoard: React.FC = () => { + const { plannedTasks, isDayStarted, isDayStale } = useTimeTracking(); + const [showNewTaskDialog, setShowNewTaskDialog] = useState(false); + + const tasksByStatus = (status: PlannedTaskStatus) => + [...plannedTasks] + .filter((t) => t.status === status) + .sort((a, b) => a.priority - b.priority || a.createdAt.localeCompare(b.createdAt)); + + return ( + <> +
+ +
+ +
+ {COLUMNS.map(({ status, title }) => ( + + ))} +
+ + setShowNewTaskDialog(false)} + /> + + ); +}; diff --git a/src/components/KanbanColumn.tsx b/src/components/KanbanColumn.tsx new file mode 100644 index 0000000..70cae72 --- /dev/null +++ b/src/components/KanbanColumn.tsx @@ -0,0 +1,88 @@ +import React, { useState } from "react"; +import { PlannedTask, PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; +import { PlannedTaskCard } from "@/components/PlannedTaskCard"; +import { PlannedTaskDialog } from "@/components/PlannedTaskDialog"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@radix-ui/themes"; +import { Plus } from "lucide-react"; + +interface KanbanColumnProps { + status: PlannedTaskStatus; + title: string; + tasks: PlannedTask[]; + isDayStarted: boolean; + isDayStale: boolean; +} + +const COLUMN_BADGE_COLORS: Record["color"]> = { + todo: undefined, + in_progress: "indigo", + done: "green", + blocked: "red", +}; + +export const KanbanColumn: React.FC = ({ + status, + title, + tasks, + isDayStarted, + isDayStale, +}) => { + const [showAddDialog, setShowAddDialog] = useState(false); + + return ( + <> + + + + {title} + + {tasks.length} + + + + + + {tasks.map((task) => ( + + ))} + + {tasks.length === 0 && ( +

+ No tasks +

+ )} + + {status === "todo" && ( + + )} +
+
+ + setShowAddDialog(false)} + defaultStatus={status} + /> + + ); +}; diff --git a/src/components/PlannedTaskCard.tsx b/src/components/PlannedTaskCard.tsx new file mode 100644 index 0000000..0fae6d9 --- /dev/null +++ b/src/components/PlannedTaskCard.tsx @@ -0,0 +1,240 @@ +import React, { useRef, useState } from "react"; +import { PlannedTask, PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; +import { useHaptics } from "@/hooks/useHaptics"; +import { useLongPress } from "@/hooks/useLongPress"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog"; +import { PlannedTaskDialog } from "@/components/PlannedTaskDialog"; +import { MarkdownDisplay } from "@/components/MarkdownDisplay"; +import { Badge } from "@radix-ui/themes"; +import { ArrowRight, Edit, Trash2, Play, MoveRight } from "lucide-react"; + +interface PlannedTaskCardProps { + task: PlannedTask; + isDayStarted: boolean; + isDayStale: boolean; +} + +const STATUS_LABELS: Record = { + todo: "To Do", + in_progress: "In Progress", + done: "Done", + blocked: "Blocked", +}; + +const OTHER_STATUSES = (current: PlannedTaskStatus): PlannedTaskStatus[] => + (["todo", "in_progress", "done", "blocked"] as PlannedTaskStatus[]).filter( + (s) => s !== current + ); + +export const PlannedTaskCard: React.FC = ({ + task, + isDayStarted, + isDayStale, +}) => { + const { categories, deletePlannedTask, movePlannedTask, pullPlannedTaskToDay } = + useTimeTracking(); + const { lightImpact, mediumImpact } = useHaptics(); + const contextMenuTriggerRef = useRef(null); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const longPressHandlers = useLongPress(() => { + mediumImpact(); + if (contextMenuTriggerRef.current) { + contextMenuTriggerRef.current.dispatchEvent( + new MouseEvent("contextmenu", { bubbles: true, cancelable: true }) + ); + } + }); + + const category = categories.find((c) => c.id === task.category); + const canPull = isDayStarted && !isDayStale && task.status !== "done"; + + return ( + <> + + +
+ + +
+
+

+ {task.title} +

+ + {task.description && ( +
+ +
+ )} + +
+ {category && ( + + {category.name} + + )} + {task.project && ( + + {task.project} + + )} + {task.client && ( + + {task.client} + + )} + {task.linkedTaskId && ( + + + Pulled to day + + )} +
+
+ +
+ {canPull && ( + + )} + + + + + + + {OTHER_STATUSES(task.status).map((s) => ( + movePlannedTask(task.id, s)} + > + {STATUS_LABELS[s]} + + ))} + + + + + + +
+
+
+
+
+
+ + + {canPull && ( + <> + pullPlannedTaskToDay(task.id)}> + + Pull to Day + + + + )} + { lightImpact(); setShowEditDialog(true); }}> + + Edit Task + + + + + Move to + + + {OTHER_STATUSES(task.status).map((s) => ( + movePlannedTask(task.id, s)}> + {STATUS_LABELS[s]} + + ))} + + + + { mediumImpact(); setShowDeleteDialog(true); }} + className="text-destructive focus:text-destructive" + > + + Delete Task + + +
+ + setShowEditDialog(false)} + /> + + setShowDeleteDialog(false)} + onConfirm={() => { + deletePlannedTask(task.id); + setShowDeleteDialog(false); + }} + taskTitle={task.title} + /> + + ); +}; diff --git a/src/components/PlannedTaskDialog.tsx b/src/components/PlannedTaskDialog.tsx new file mode 100644 index 0000000..7ccb468 --- /dev/null +++ b/src/components/PlannedTaskDialog.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect } from "react"; +import { + AdaptiveDialog, + AdaptiveDialogContent, + AdaptiveDialogFooter, + AdaptiveDialogHeader, + AdaptiveDialogTitle, +} from "@/components/ui/adaptive-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { MarkdownDisplay } from "@/components/MarkdownDisplay"; +import { Save } from "lucide-react"; +import { PlannedTask, PlannedTaskStatus } from "@/contexts/TimeTrackingContext"; +import { useTimeTracking } from "@/hooks/useTimeTracking"; + +interface PlannedTaskDialogProps { + task?: PlannedTask; + isOpen: boolean; + onClose: () => void; + defaultStatus?: PlannedTaskStatus; +} + +const STATUS_LABELS: Record = { + todo: "To Do", + in_progress: "In Progress", + done: "Done", + blocked: "Blocked", +}; + +export const PlannedTaskDialog: React.FC = ({ + task, + isOpen, + onClose, + defaultStatus = "todo", +}) => { + const { addPlannedTask, updatePlannedTask, projects, categories } = useTimeTracking(); + + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState(defaultStatus); + const [project, setProject] = useState("none"); + const [category, setCategory] = useState("none"); + const [descTab, setDescTab] = useState<"edit" | "preview">("edit"); + const [hasChanges, setHasChanges] = useState(false); + + const isEditMode = !!task; + + useEffect(() => { + if (isOpen) { + if (task) { + setTitle(task.title); + setDescription(task.description ?? ""); + setStatus(task.status); + setProject(projects.find((p) => p.name === task.project)?.id ?? "none"); + setCategory(task.category ?? "none"); + } else { + setTitle(""); + setDescription(""); + setStatus(defaultStatus); + setProject("none"); + setCategory("none"); + } + setDescTab("edit"); + setHasChanges(false); + } + }, [isOpen, task, projects, defaultStatus]); + + useEffect(() => { + if (!isOpen || !task) return; + const originalProject = projects.find((p) => p.name === task.project)?.id ?? "none"; + const changed = + title !== task.title || + description !== (task.description ?? "") || + status !== task.status || + project !== originalProject || + category !== (task.category ?? "none"); + setHasChanges(changed); + }, [title, description, status, project, category, task, projects, isOpen]); + + const handleSave = () => { + if (!title.trim()) return; + + const selectedProject = projects.find((p) => p.id === project); + const data = { + title: title.trim(), + description: description.trim() || undefined, + project: selectedProject?.name, + client: selectedProject?.client, + category: category === "none" ? undefined : category, + priority: task?.priority ?? 0, + linkedTaskId: task?.linkedTaskId, + }; + + if (isEditMode && task) { + updatePlannedTask(task.id, { ...data, status }); + } else { + addPlannedTask(data); + } + onClose(); + }; + + const canSave = title.trim().length > 0 && (!isEditMode || hasChanges); + + return ( + + + + + {isEditMode ? "Edit Planned Task" : "New Planned Task"} + + + +
+
+ + setTitle(e.target.value)} + placeholder="What needs to be done?" + onKeyDown={(e) => e.key === "Enter" && canSave && handleSave()} + /> +
+ +
+ + setDescTab(v as "edit" | "preview")}> + + Edit + + Preview + + + +