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
+
+
+
+
+
+
+
+
+
+
+
+
+ {isEditMode && (
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/StaleDayDialog.tsx b/src/components/StaleDayDialog.tsx
new file mode 100644
index 0000000..26f5b0d
--- /dev/null
+++ b/src/components/StaleDayDialog.tsx
@@ -0,0 +1,82 @@
+import React, { useState, useEffect } from "react";
+import {
+ AdaptiveDialog,
+ AdaptiveDialogContent,
+ AdaptiveDialogHeader,
+ AdaptiveDialogTitle,
+ AdaptiveDialogDescription,
+ AdaptiveDialogFooter,
+} from "@/components/ui/adaptive-dialog";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { TimePicker } from "@/components/ui/scroll-time-picker";
+import { useTimeTracking } from "@/hooks/useTimeTracking";
+import { AlertTriangle } from "lucide-react";
+
+function formatDateLabel(date: Date): string {
+ return date.toLocaleDateString(undefined, {
+ weekday: "long",
+ month: "long",
+ day: "numeric"
+ });
+}
+
+export const StaleDayDialog: React.FC = () => {
+ const { isDayStale, dayStartTime, endDay, discardDay } = useTimeTracking();
+ const [selectedTime, setSelectedTime] = useState("23:59");
+
+ useEffect(() => {
+ if (isDayStale) {
+ setSelectedTime("23:59");
+ }
+ }, [isDayStale]);
+
+ const handleEndDay = () => {
+ if (!dayStartTime) return;
+ const [hours, minutes] = selectedTime.split(":").map(Number);
+ const endDateTime = new Date(dayStartTime);
+ endDateTime.setHours(hours, minutes, 0, 0);
+ endDay(endDateTime);
+ };
+
+ return (
+ {}} snapPoints={[0.6, 1]}>
+
+
+
+
+ Unfinished Work Day
+
+
+ You have an open work day from{" "}
+ {dayStartTime ? formatDateLabel(dayStartTime) : "a previous day"}.
+ When did you finish working?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/contexts/TimeTrackingContext.tsx b/src/contexts/TimeTrackingContext.tsx
index a876567..d0dfce8 100644
--- a/src/contexts/TimeTrackingContext.tsx
+++ b/src/contexts/TimeTrackingContext.tsx
@@ -73,6 +73,22 @@ export interface TodoItem {
completedAt?: string; // ISO string — set when toggled to done, cleared when toggled back
}
+export type PlannedTaskStatus = "todo" | "in_progress" | "done" | "blocked";
+
+export interface PlannedTask {
+ id: string;
+ title: string;
+ description?: string;
+ status: PlannedTaskStatus;
+ project?: string;
+ client?: string;
+ category?: string;
+ priority: number; // integer sort order within column; lower = higher; default 0
+ linkedTaskId?: string; // id of the timed Task created via "Pull to Day" (display only)
+ createdAt: string; // ISO string
+ updatedAt: string; // ISO string
+}
+
export interface TimeEntry {
id: string;
date: string;
@@ -128,9 +144,12 @@ interface TimeTrackingContextType {
// Categories
categories: TaskCategory[];
+ // Stale day detection
+ isDayStale: boolean;
+
// Actions
startDay: (startDateTime?: Date) => void;
- endDay: () => void;
+ endDay: (endDateTime?: Date) => void;
discardDay: () => void;
startNewTask: (
title: string,
@@ -138,7 +157,7 @@ interface TimeTrackingContextType {
project?: string,
client?: string,
category?: string
- ) => void;
+ ) => string;
updateTask: (taskId: string, updates: Partial) => void;
deleteTask: (taskId: string) => void;
postDay: (notes?: string) => void;
@@ -174,6 +193,14 @@ interface TimeTrackingContextType {
) => Promise<{ success: boolean; message: string; importedCount: number }>;
// generateInvoiceData: (clientName: string, startDate: Date, endDate: Date) => any;
+ // Planned tasks (kanban board)
+ plannedTasks: PlannedTask[];
+ addPlannedTask: (data: Omit) => void;
+ updatePlannedTask: (id: string, updates: Partial) => void;
+ deletePlannedTask: (id: string) => void;
+ movePlannedTask: (id: string, status: PlannedTaskStatus) => void;
+ pullPlannedTaskToDay: (id: string) => void;
+
// Calculated values
getTotalDayDuration: () => number;
getCurrentTaskDuration: () => number;
@@ -231,8 +258,10 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
const [isSyncing, setIsSyncing] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [dayEndTime, setDayEndTime] = useState(null);
+ const [plannedTasks, setPlannedTasks] = useState([]);
- const { successNotify, errorNotify } = useHaptics();
+ const { successNotify, errorNotify, lightImpact, mediumImpact } = useHaptics();
// Debounce refs to manage timeouts
const saveTimeoutRef = useRef(null);
@@ -245,6 +274,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
const dataServiceRef = useRef(null);
// Guards against saving todos during the initial data load.
const todoLoadedRef = useRef(false);
+ const plannedLoadedRef = useRef(false);
// Initialize data service when auth state changes
useEffect(() => {
@@ -336,13 +366,18 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
const loadedTodos = await dataService.getTodos();
setTodoItems(loadedTodos);
+ // Load planned tasks
+ const loadedPlannedTasks = await dataService.getPlannedTasks();
+ setPlannedTasks(loadedPlannedTasks);
+
// If switching from localStorage to Supabase, migrate data
if (currentAuthStateRef.current && dataService) {
await dataService.migrateFromLocalStorage();
}
- // Allow todo save effect to fire for user-initiated changes going forward
+ // Allow todo/planned-task save effects to fire for user-initiated changes
todoLoadedRef.current = true;
+ plannedLoadedRef.current = true;
} catch (error) {
console.error('Error loading data:', error);
} finally {
@@ -421,7 +456,8 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
dataService.saveProjects(projects),
dataService.saveCategories(categories),
dataService.saveArchivedDays(archivedDays),
- dataService.saveTodos(todoItems)
+ dataService.saveTodos(todoItems),
+ dataService.savePlannedTasks(plannedTasks)
]);
const failed = results.filter((r) => r.status === "rejected");
@@ -443,7 +479,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
} finally {
setIsSyncing(false);
}
- }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems, errorNotify]);
+ }, [dataService, stableSaveCurrentDay, projects, categories, archivedDays, todoItems, plannedTasks, errorNotify]);
// Load current day data (for periodic sync)
const loadCurrentDay = useCallback(async () => {
@@ -529,6 +565,11 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
return () => clearInterval(timer);
}, []);
+ const isDayStale =
+ isDayStarted &&
+ !!dayStartTime &&
+ dayStartTime.toDateString() !== new Date().toDateString();
+
const startDay = (startDateTime?: Date) => {
const now = startDateTime || new Date();
@@ -549,18 +590,20 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
}
};
- const endDay = () => {
+ const endDay = (endDateTime?: Date) => {
+ const effectiveEndTime = endDateTime ?? new Date();
let finalTasks = tasks;
if (currentTask) {
const endedTask = {
...currentTask,
- endTime: new Date(),
- duration: new Date().getTime() - currentTask.startTime.getTime()
+ endTime: effectiveEndTime,
+ duration: effectiveEndTime.getTime() - currentTask.startTime.getTime()
};
finalTasks = tasks.map(t => (t.id === currentTask.id ? endedTask : t));
setTasks(finalTasks);
setCurrentTask(null);
}
+ setDayEndTime(effectiveEndTime);
setIsDayStarted(false);
setHasUnsavedChanges(true);
// Save with freshly computed state to avoid reading from stale latestStateRef
@@ -597,7 +640,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
project?: string,
client?: string,
category?: string
- ) => {
+ ): string => {
const now = new Date();
// End current task if exists and compute the updated task list synchronously
@@ -638,6 +681,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
.then(() => setLastSyncTime(new Date()))
.catch(error => console.error("❌ Error saving after starting task:", error));
}
+ return newTask.id;
};
const updateTask = (taskId: string, updates: Partial) => {
@@ -694,7 +738,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
tasks: tasks,
totalDuration: getTotalDayDuration(),
startTime: dayStartTime,
- endTime: new Date(),
+ endTime: dayEndTime ?? new Date(),
notes
};
@@ -712,6 +756,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
// Clear current day data
setDayStartTime(null);
+ setDayEndTime(null);
setCurrentTask(null);
setTasks([]);
setIsDayStarted(false);
@@ -1014,6 +1059,12 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
dataServiceRef.current.saveTodos(todoItems);
}, [todoItems]);
+ // Persist planned tasks whenever plannedTasks changes due to a user action.
+ useEffect(() => {
+ if (!plannedLoadedRef.current || !dataServiceRef.current) return;
+ dataServiceRef.current.savePlannedTasks(plannedTasks);
+ }, [plannedTasks]);
+
// Stable callbacks — no todoItems in deps. Functional updates ensure each
// callback always operates on the latest state without closing over it.
const addTodoItem = useCallback((text: string) => {
@@ -1044,6 +1095,57 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
setTodoItems(prev => prev.filter(item => !item.completed));
}, []);
+ // === PLANNED TASKS ===
+
+ const addPlannedTask = useCallback((data: Omit) => {
+ const now = new Date().toISOString();
+ const newTask: PlannedTask = {
+ ...data,
+ id: `planned-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ status: "todo",
+ createdAt: now,
+ updatedAt: now
+ };
+ setPlannedTasks(prev => [...prev, newTask]);
+ successNotify();
+ }, [successNotify]);
+
+ const updatePlannedTask = useCallback((id: string, updates: Partial) => {
+ setPlannedTasks(prev => prev.map(t =>
+ t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t
+ ));
+ }, []);
+
+ const deletePlannedTask = useCallback((id: string) => {
+ setPlannedTasks(prev => prev.filter(t => t.id !== id));
+ mediumImpact();
+ }, [mediumImpact]);
+
+ const movePlannedTask = useCallback((id: string, status: PlannedTaskStatus) => {
+ setPlannedTasks(prev => prev.map(t =>
+ t.id === id ? { ...t, status, updatedAt: new Date().toISOString() } : t
+ ));
+ lightImpact();
+ }, [lightImpact]);
+
+ const pullPlannedTaskToDay = (id: string) => {
+ if (!isDayStarted) {
+ toast({ title: "Start your work day first", description: "Go to the Dashboard and start your day before pulling tasks." });
+ return;
+ }
+ if (isDayStale) {
+ toast({ title: "Resolve your previous day first", description: "Your previous work day is still open. Please end or discard it." });
+ return;
+ }
+ const task = plannedTasks.find(t => t.id === id);
+ if (!task) return;
+ const newTaskId = startNewTask(task.title, task.description, task.project, task.client, task.category);
+ setPlannedTasks(prev => prev.map(t =>
+ t.id === id ? { ...t, status: "in_progress" as PlannedTaskStatus, linkedTaskId: newTaskId, updatedAt: new Date().toISOString() } : t
+ ));
+ toast({ title: `Task started: ${task.title}` });
+ };
+
const importFromCSV = async (
csvContent: string
): Promise<{ success: boolean; message: string; importedCount: number }> => {
@@ -1085,6 +1187,7 @@ export const TimeTrackingProvider: React.FC<{ children: React.ReactNode }> = ({
= ({
updateTask,
deleteTask,
postDay,
+ plannedTasks,
+ addPlannedTask,
+ updatePlannedTask,
+ deletePlannedTask,
+ movePlannedTask,
+ pullPlannedTaskToDay,
addProject,
updateProject,
deleteProject,
diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx
index eb5e669..06907e8 100644
--- a/src/pages/Index.tsx
+++ b/src/pages/Index.tsx
@@ -1,6 +1,7 @@
import { useTimeTracking } from "@/hooks/useTimeTracking";
import { DaySummary } from "@/components/DaySummary";
import { StartDayDialog } from "@/components/StartDayDialog";
+import { StaleDayDialog } from "@/components/StaleDayDialog";
import { TaskItem } from "@/components/TaskItem";
import { NewTaskForm } from "@/components/NewTaskForm";
import { Button } from "@/components/ui/button";
@@ -17,6 +18,7 @@ const EPOCH = new Date(0);
const TimeTrackerContent = () => {
const {
isDayStarted,
+ isDayStale,
dayStartTime,
currentTask,
tasks,
@@ -96,10 +98,11 @@ const TimeTrackerContent = () => {
}>
setShowStartDayDialog(false)}
onStartDay={handleStartDayWithDateTime}
/>
+
{/* Stats (always visible) */}
{!isDayStarted && (
diff --git a/src/pages/TaskList.tsx b/src/pages/TaskList.tsx
index 94faba4..99d8b75 100644
--- a/src/pages/TaskList.tsx
+++ b/src/pages/TaskList.tsx
@@ -1,140 +1,15 @@
-import { useTimeTracking } from "@/hooks/useTimeTracking";
-import { TaskItem } from "@/components/TaskItem";
-import { NewTaskForm } from "@/components/NewTaskForm";
-import { DaySummary } from "@/components/DaySummary";
import { PageLayout } from "@/components/PageLayout";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { CircleStop, ClipboardList, LayoutDashboard } from "lucide-react";
-import { Link } from "react-router-dom";
+import { KanbanBoard } from "@/components/KanbanBoard";
+import { LayoutGrid } from "lucide-react";
const TaskList = () => {
- const {
- isDayStarted,
- dayStartTime,
- currentTask,
- tasks,
- endDay,
- postDay,
- deleteTask,
- startNewTask,
- getTotalDayDuration,
- getCurrentTaskDuration,
- } = useTimeTracking();
-
- const handleEndDay = () => {
- endDay();
- };
-
- const handlePostDay = () => {
- postDay();
- };
-
- const handleNewTask = (
- title: string,
- description?: string,
- project?: string,
- client?: string,
- category?: string
- ) => {
- startNewTask(title, description, project, client, category);
- };
-
- const handleTaskDelete = (taskId: string) => {
- deleteTask(taskId);
- };
-
- // Show day summary after day ends but before it is posted
- if (!isDayStarted && dayStartTime && tasks.length > 0) {
- return (
-
-
-
-
-
- );
- }
-
- if (!isDayStarted) {
- return (
- }
- >
-
-
-
-
-
- No Active Work Day
-
-
-
-
- Start your work day on the dashboard before adding tasks.
-
-
-
-
-
-
- );
- }
-
return (
}
+ icon={}
>
-
-
- {tasks.length === 0 ? (
-
-
-
No tasks yet
-
Use the button above to start tracking your first task.
-
- ) : (
-
-
- Tasks ({tasks.length})
- {dayStartTime && (
-
- Day started at: {dayStartTime.toLocaleTimeString()}
-
- )}
-
- {tasks.map((task) => (
-
- ))}
-
- )}
-
+
+
);
diff --git a/src/services/dataService.ts b/src/services/dataService.ts
index bd18b8c..9540b39 100644
--- a/src/services/dataService.ts
+++ b/src/services/dataService.ts
@@ -1,4 +1,4 @@
-import { DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext";
+import { DayRecord, Project, TodoItem, PlannedTask } from "@/contexts/TimeTrackingContext";
import { Task } from "@/contexts/TimeTrackingContext";
import { TaskCategory } from "@/config/categories";
import { LocalStorageService } from "@/services/localStorageService";
@@ -38,6 +38,10 @@ export interface DataService {
saveTodos: (todos: TodoItem[]) => Promise
;
getTodos: () => Promise;
+ // Planned tasks operations
+ savePlannedTasks: (tasks: PlannedTask[]) => Promise;
+ getPlannedTasks: () => Promise;
+
// Migration operations
migrateFromLocalStorage: () => Promise;
migrateToLocalStorage: () => Promise;
diff --git a/src/services/localStorageService/constants.ts b/src/services/localStorageService/constants.ts
index d6945f6..019930c 100644
--- a/src/services/localStorageService/constants.ts
+++ b/src/services/localStorageService/constants.ts
@@ -3,7 +3,8 @@ export const STORAGE_KEYS = {
ARCHIVED_DAYS: "timetracker_archived_days",
PROJECTS: "timetracker_projects",
CATEGORIES: "timetracker_categories",
- TODOS: "timetracker_todos"
+ TODOS: "timetracker_todos",
+ PLANNED_TASKS: "timetracker_planned_tasks"
};
// Increment this when the stored data format changes in a breaking way.
diff --git a/src/services/localStorageService/index.ts b/src/services/localStorageService/index.ts
index 9249aef..4f3be51 100644
--- a/src/services/localStorageService/index.ts
+++ b/src/services/localStorageService/index.ts
@@ -1,4 +1,4 @@
-import type { DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext";
+import type { DayRecord, Project, TodoItem, PlannedTask } from "@/contexts/TimeTrackingContext";
import type { TaskCategory } from "@/config/categories";
import type { DataService, CurrentDayData } from "@/services/dataService";
import { saveCurrentDay, getCurrentDay } from "./currentDay";
@@ -11,6 +11,7 @@ import {
import { saveProjects, getProjects } from "./projects";
import { saveCategories, getCategories } from "./categories";
import { saveTodos, getTodos } from "./todos";
+import { savePlannedTasks, getPlannedTasks } from "./plannedTasks";
export { STORAGE_KEYS, SCHEMA_VERSION } from "./constants";
@@ -63,6 +64,14 @@ export class LocalStorageService implements DataService {
return getTodos();
}
+ savePlannedTasks(tasks: PlannedTask[]): Promise {
+ return savePlannedTasks(tasks);
+ }
+
+ getPlannedTasks(): Promise {
+ return getPlannedTasks();
+ }
+
async migrateFromLocalStorage(): Promise {
// No-op for localStorage service
}
diff --git a/src/services/localStorageService/plannedTasks.ts b/src/services/localStorageService/plannedTasks.ts
new file mode 100644
index 0000000..0001144
--- /dev/null
+++ b/src/services/localStorageService/plannedTasks.ts
@@ -0,0 +1,23 @@
+import { PlannedTask } from "@/contexts/TimeTrackingContext";
+import { STORAGE_KEYS, SCHEMA_VERSION } from "./constants";
+import { readVersioned } from "./utils";
+
+export async function savePlannedTasks(tasks: PlannedTask[]): Promise {
+ try {
+ localStorage.setItem(
+ STORAGE_KEYS.PLANNED_TASKS,
+ JSON.stringify({ data: tasks, _v: SCHEMA_VERSION })
+ );
+ } catch (error) {
+ console.warn("Failed to save planned tasks to localStorage:", error);
+ }
+}
+
+export async function getPlannedTasks(): Promise {
+ try {
+ return readVersioned(STORAGE_KEYS.PLANNED_TASKS, "data");
+ } catch (error) {
+ console.error("Error loading planned tasks from localStorage:", error);
+ return [];
+ }
+}
diff --git a/src/services/supabaseService.ts b/src/services/supabaseService.ts
index 120ab23..804481e 100644
--- a/src/services/supabaseService.ts
+++ b/src/services/supabaseService.ts
@@ -9,7 +9,7 @@ import {
clearDataCaches,
trackAuthCall
} from "@/lib/supabase";
-import { Task, DayRecord, Project, TodoItem } from "@/contexts/TimeTrackingContext";
+import { Task, DayRecord, Project, TodoItem, PlannedTask } from "@/contexts/TimeTrackingContext";
import { TaskCategory } from "@/config/categories";
import { DataService, CurrentDayData } from "@/services/dataService";
import { LocalStorageService } from "@/services/localStorageService";
@@ -876,6 +876,99 @@ export class SupabaseService implements DataService {
}));
}
+ async savePlannedTasks(tasks: PlannedTask[]): Promise {
+ const user = await this.requireUser();
+
+ if (tasks.length === 0) {
+ await supabase.from("planned_tasks").delete().eq("user_id", user.id);
+ trackDbCall("delete", "planned_tasks");
+ return;
+ }
+
+ const { data: existingRows } = await supabase
+ .from("planned_tasks")
+ .select("id")
+ .eq("user_id", user.id);
+ trackDbCall("select", "planned_tasks");
+
+ const existingIds = new Set(existingRows?.map((r: { id: string }) => r.id) || []);
+ const newIds = new Set(tasks.map((t) => t.id));
+
+ const toDelete = Array.from(existingIds).filter((id) => !newIds.has(id));
+ if (toDelete.length > 0) {
+ const { error: deleteError } = await supabase
+ .from("planned_tasks")
+ .delete()
+ .eq("user_id", user.id)
+ .in("id", toDelete);
+ trackDbCall("delete", "planned_tasks");
+ if (deleteError) throw deleteError;
+ }
+
+ const toUpsert = tasks.map((t) => ({
+ id: t.id,
+ user_id: user.id,
+ title: t.title,
+ description: t.description ?? null,
+ status: t.status,
+ project_name: t.project ?? null,
+ client: t.client ?? null,
+ category_id: t.category ?? null,
+ priority: t.priority,
+ linked_task_id: t.linkedTaskId ?? null,
+ created_at: t.createdAt
+ }));
+
+ const { error } = await supabase
+ .from("planned_tasks")
+ .upsert(toUpsert, { onConflict: "id" });
+ trackDbCall("upsert", "planned_tasks");
+ if (error) throw error;
+ }
+
+ async getPlannedTasks(): Promise {
+ const user = await this.requireUser();
+
+ const { data, error } = await supabase
+ .from("planned_tasks")
+ .select("*")
+ .eq("user_id", user.id)
+ .order("priority", { ascending: true })
+ .order("created_at", { ascending: true });
+ trackDbCall("select", "planned_tasks");
+
+ if (error) {
+ console.error("❌ Error loading planned tasks:", error);
+ throw error;
+ }
+
+ return (data || []).map((row: {
+ id: string;
+ title: string;
+ description: string | null;
+ status: string;
+ project_name: string | null;
+ client: string | null;
+ category_id: string | null;
+ priority: number;
+ linked_task_id: string | null;
+ created_at: string;
+ updated_at: string;
+ }) => ({
+ id: row.id,
+ title: row.title,
+ description: row.description ?? undefined,
+ status: row.status as PlannedTask["status"],
+ project: row.project_name ?? undefined,
+ client: row.client ?? undefined,
+ category: row.category_id ?? undefined,
+ priority: row.priority,
+ linkedTaskId: row.linked_task_id ?? undefined,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ }));
+ }
+
async migrateFromLocalStorage(): Promise {
try {
const localService = new LocalStorageService();
@@ -885,6 +978,7 @@ export class SupabaseService implements DataService {
const currentDay = await localService.getCurrentDay();
const archivedDays = await localService.getArchivedDays();
const todos = await localService.getTodos();
+ const plannedTasks = await localService.getPlannedTasks();
const hasProjects = projects.length > 0;
const hasCategories = categories.length > 0;
@@ -892,8 +986,9 @@ export class SupabaseService implements DataService {
currentDay && (currentDay.tasks.length > 0 || currentDay.isDayStarted);
const hasArchivedDays = archivedDays.length > 0;
const hasTodos = todos.length > 0;
+ const hasPlannedTasks = plannedTasks.length > 0;
- if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays && !hasTodos) {
+ if (!hasProjects && !hasCategories && !hasCurrentDay && !hasArchivedDays && !hasTodos && !hasPlannedTasks) {
return;
}
@@ -941,6 +1036,10 @@ export class SupabaseService implements DataService {
if (hasTodos) {
await this.saveTodos(todos);
}
+
+ if (hasPlannedTasks) {
+ await this.savePlannedTasks(plannedTasks);
+ }
} else {
if (hasProjects) await this.saveProjects(projects);
@@ -948,6 +1047,7 @@ export class SupabaseService implements DataService {
if (hasCurrentDay) await this.saveCurrentDay(currentDay);
if (hasArchivedDays) await this.saveArchivedDays(archivedDays);
if (hasTodos) await this.saveTodos(todos);
+ if (hasPlannedTasks) await this.savePlannedTasks(plannedTasks);
}
} catch (error) {
@@ -964,6 +1064,7 @@ export class SupabaseService implements DataService {
const projects = await this.getProjects();
const categories = await this.getCategories();
const todos = await this.getTodos();
+ const plannedTasks = await this.getPlannedTasks();
if (currentDay) {
await localService.saveCurrentDay(currentDay);
@@ -985,6 +1086,10 @@ export class SupabaseService implements DataService {
await localService.saveTodos(todos);
}
+ if (plannedTasks.length > 0) {
+ await localService.savePlannedTasks(plannedTasks);
+ }
+
} catch (error) {
console.error("❌ Error migrating data to localStorage:", error);
}
diff --git a/supabase/migrations/20260525_planned_tasks.sql b/supabase/migrations/20260525_planned_tasks.sql
new file mode 100644
index 0000000..9018e59
--- /dev/null
+++ b/supabase/migrations/20260525_planned_tasks.sql
@@ -0,0 +1,40 @@
+-- Planned tasks table for kanban board feature
+-- Tasks created here are planning artifacts separate from time-tracked tasks (current_day.tasks)
+
+CREATE TABLE IF NOT EXISTS planned_tasks (
+ id text PRIMARY KEY,
+ user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
+ title text NOT NULL,
+ description text,
+ status text NOT NULL DEFAULT 'todo',
+ project_name text,
+ client text,
+ category_id text,
+ priority integer NOT NULL DEFAULT 0,
+ linked_task_id text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ inserted_at timestamptz DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_planned_tasks_user_id ON planned_tasks(user_id);
+CREATE INDEX IF NOT EXISTS idx_planned_tasks_status ON planned_tasks(status);
+CREATE INDEX IF NOT EXISTS idx_planned_tasks_priority ON planned_tasks(priority);
+
+CREATE TRIGGER trg_update_planned_tasks_updated_at
+BEFORE UPDATE ON planned_tasks
+FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
+
+ALTER TABLE planned_tasks ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users can view their own planned tasks"
+ ON planned_tasks FOR SELECT USING (auth.uid() = user_id);
+
+CREATE POLICY "Users can insert their own planned tasks"
+ ON planned_tasks FOR INSERT WITH CHECK (auth.uid() = user_id);
+
+CREATE POLICY "Users can update their own planned tasks"
+ ON planned_tasks FOR UPDATE USING (auth.uid() = user_id);
+
+CREATE POLICY "Users can delete their own planned tasks"
+ ON planned_tasks FOR DELETE USING (auth.uid() = user_id);