feat: recurring tasks - auto-spawn next instance on completion
- Added recurrence field (daily/weekly/biweekly/monthly) to tasks schema
- Backend: auto-creates next task instance when recurring task completed
- Copies title, description, assignee, project, tags, subtasks (unchecked)
- Computes next due date based on frequency
- Optional autoActivate to immediately activate next instance
- Frontend: recurrence picker in CreateTaskModal and TaskDetailPanel
- Recurrence badges (🔄) on TaskCard, KanbanBoard, TaskPage, DashboardPage
- Schema uses JSONB column (no migration needed, db:push on deploy)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchProjects } from "../lib/api";
|
||||
import type { Project } from "../lib/types";
|
||||
import type { Project, RecurrenceFrequency, Recurrence } from "../lib/types";
|
||||
|
||||
interface CreateTaskModalProps {
|
||||
open: boolean;
|
||||
@@ -13,6 +13,7 @@ interface CreateTaskModalProps {
|
||||
projectId?: string;
|
||||
dueDate?: string;
|
||||
estimatedHours?: number;
|
||||
recurrence?: Recurrence | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [estimatedHours, setEstimatedHours] = useState("");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [recurrenceFreq, setRecurrenceFreq] = useState<RecurrenceFrequency | "">("");
|
||||
const [recurrenceAutoActivate, setRecurrenceAutoActivate] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -56,6 +59,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
||||
projectId: projectId || undefined,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||
estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
|
||||
recurrence: recurrenceFreq ? { frequency: recurrenceFreq, autoActivate: recurrenceAutoActivate } : undefined,
|
||||
});
|
||||
// Reset form
|
||||
setTitle("");
|
||||
@@ -65,6 +69,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
||||
setProjectId("");
|
||||
setDueDate("");
|
||||
setEstimatedHours("");
|
||||
setRecurrenceFreq("");
|
||||
setRecurrenceAutoActivate(false);
|
||||
setShowAdvanced(false);
|
||||
onClose();
|
||||
};
|
||||
@@ -200,6 +206,38 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 block mb-1">Recurrence</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(["", "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => (
|
||||
<button
|
||||
key={freq || "none"}
|
||||
type="button"
|
||||
onClick={() => setRecurrenceFreq(freq as RecurrenceFrequency | "")}
|
||||
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
|
||||
recurrenceFreq === freq
|
||||
? "bg-teal-500 text-white border-teal-500"
|
||||
: "bg-white text-gray-500 border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{freq === "" ? "None" : freq === "biweekly" ? "Bi-weekly" : freq.charAt(0).toUpperCase() + freq.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{recurrenceFreq && (
|
||||
<label className="flex items-center gap-2 mt-2 text-xs text-gray-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={recurrenceAutoActivate}
|
||||
onChange={(e) => setRecurrenceAutoActivate(e.target.checked)}
|
||||
className="rounded border-gray-300 text-teal-500 focus:ring-teal-400"
|
||||
/>
|
||||
Auto-activate next instance
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label>
|
||||
|
||||
@@ -118,6 +118,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
|
||||
🏷️ {tag}
|
||||
</span>
|
||||
))}
|
||||
{task.recurrence && (
|
||||
<span className="text-[10px] text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 px-1.5 py-0.5 rounded-full">
|
||||
🔄 {task.recurrence.frequency}
|
||||
</span>
|
||||
)}
|
||||
{task.estimatedHours != null && task.estimatedHours > 0 && (
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
⏱ {task.estimatedHours}h
|
||||
|
||||
@@ -133,6 +133,11 @@ export function TaskCard({
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
|
||||
{timeAgo(task.createdAt)}
|
||||
</span>
|
||||
{task.recurrence && (
|
||||
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400">
|
||||
🔄 {task.recurrence.frequency}
|
||||
</span>
|
||||
)}
|
||||
{task.estimatedHours != null && task.estimatedHours > 0 && (
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
|
||||
⏱ {task.estimatedHours}h
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
|
||||
import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
|
||||
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||
import { useToast } from "./Toast";
|
||||
|
||||
@@ -261,6 +261,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
const [draftEstimatedHours, setDraftEstimatedHours] = useState<string>(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
const [draftRecurrence, setDraftRecurrence] = useState<Recurrence | null>(task.recurrence || null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
||||
const [addingSubtask, setAddingSubtask] = useState(false);
|
||||
@@ -287,7 +288,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
setDraftAssigneeName(task.assigneeName || "");
|
||||
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||
setDraftTags(task.tags || []);
|
||||
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.estimatedHours, task.tags]);
|
||||
setDraftRecurrence(task.recurrence || null);
|
||||
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.estimatedHours, task.tags, task.recurrence]);
|
||||
|
||||
const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
|
||||
const isDirty =
|
||||
@@ -299,7 +301,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
draftDueDate !== currentDueDate ||
|
||||
draftAssigneeName !== (task.assigneeName || "") ||
|
||||
draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "") ||
|
||||
JSON.stringify(draftTags) !== JSON.stringify(task.tags || []);
|
||||
JSON.stringify(draftTags) !== JSON.stringify(task.tags || []) ||
|
||||
JSON.stringify(draftRecurrence) !== JSON.stringify(task.recurrence || null);
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraftTitle(task.title);
|
||||
@@ -311,6 +314,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
setDraftAssigneeName(task.assigneeName || "");
|
||||
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||
setDraftTags(task.tags || []);
|
||||
setDraftRecurrence(task.recurrence || null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -329,6 +333,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
(updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null;
|
||||
}
|
||||
if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags;
|
||||
if (JSON.stringify(draftRecurrence) !== JSON.stringify(task.recurrence || null)) (updates as any).recurrence = draftRecurrence;
|
||||
await updateTask(task.id, updates, token);
|
||||
onTaskUpdated();
|
||||
toast("Changes saved", "success");
|
||||
@@ -672,6 +677,62 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurrence */}
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Recurrence</h3>
|
||||
{hasToken ? (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{([null, "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => (
|
||||
<button
|
||||
key={freq || "none"}
|
||||
onClick={() => {
|
||||
if (freq === null) {
|
||||
setDraftRecurrence(null);
|
||||
} else {
|
||||
setDraftRecurrence({ frequency: freq, autoActivate: draftRecurrence?.autoActivate });
|
||||
}
|
||||
}}
|
||||
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
|
||||
(freq === null && !draftRecurrence) || (draftRecurrence && freq === draftRecurrence.frequency)
|
||||
? "bg-teal-500 text-white border-teal-500"
|
||||
: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{freq === null ? "None" : freq === "biweekly" ? "Bi-weekly" : freq.charAt(0).toUpperCase() + freq.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{draftRecurrence && (
|
||||
<label className="flex items-center gap-2 mt-2.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftRecurrence.autoActivate || false}
|
||||
onChange={(e) => setDraftRecurrence({ ...draftRecurrence, autoActivate: e.target.checked })}
|
||||
className="rounded border-gray-300 dark:border-gray-600 text-teal-500 focus:ring-teal-400"
|
||||
/>
|
||||
Auto-activate next instance when completed
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{task.recurrence ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="text-xs px-2 py-1 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full font-medium">
|
||||
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
|
||||
</span>
|
||||
{task.recurrence.autoActivate && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">· auto-activate</span>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">One-time task</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
||||
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, Project, ProjectWithTasks, VelocityStats } from "./types";
|
||||
import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence } from "./types";
|
||||
|
||||
const BASE = "/api/tasks";
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise<void>
|
||||
}
|
||||
|
||||
export async function createTask(
|
||||
task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string; estimatedHours?: number },
|
||||
task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string; estimatedHours?: number; recurrence?: Recurrence | null },
|
||||
token?: string
|
||||
): Promise<Task> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
@@ -44,6 +44,13 @@ export interface ProjectWithTasks extends Project {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
|
||||
|
||||
export interface Recurrence {
|
||||
frequency: RecurrenceFrequency;
|
||||
autoActivate?: boolean;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
taskNumber: number;
|
||||
@@ -59,6 +66,7 @@ export interface Task {
|
||||
dueDate: string | null;
|
||||
estimatedHours: number | null;
|
||||
tags: string[];
|
||||
recurrence: Recurrence | null;
|
||||
subtasks: Subtask[];
|
||||
progressNotes: ProgressNote[];
|
||||
createdAt: string;
|
||||
|
||||
@@ -255,6 +255,9 @@ export function DashboardPage() {
|
||||
{task.assigneeName && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">👤 {task.assigneeName}</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
<span className="text-xs text-teal-600 dark:text-teal-400 px-1.5 py-0.5 bg-teal-100 dark:bg-teal-900/30 rounded-full">🔄 {task.recurrence.frequency}</span>
|
||||
)}
|
||||
{task.dueDate && (() => {
|
||||
const due = new Date(task.dueDate);
|
||||
const diffMs = due.getTime() - Date.now();
|
||||
|
||||
@@ -248,6 +248,11 @@ export function TaskPage() {
|
||||
📁 {project.name}
|
||||
</span>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
<span className="text-xs text-teal-600 dark:text-teal-400 bg-teal-100 dark:bg-teal-900/30 px-2 py-0.5 rounded-full font-medium">
|
||||
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
{task.tags?.map(tag => (
|
||||
<span key={tag} className="text-xs text-violet-600 dark:text-violet-400 bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 rounded-full font-medium">
|
||||
🏷️ {tag}
|
||||
@@ -623,6 +628,14 @@ export function TaskPage() {
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">⏱ {task.estimatedHours}h</span>
|
||||
</div>
|
||||
)}
|
||||
{task.recurrence && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Recurrence</span>
|
||||
<span className="text-teal-600 dark:text-teal-400 font-medium">
|
||||
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.tags?.length > 0 && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>
|
||||
|
||||
Reference in New Issue
Block a user