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:
2026-01-29 12:05:13 +00:00
parent dd401290c1
commit 96441b818e
10 changed files with 246 additions and 7 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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" };

View File

@@ -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;

View File

@@ -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();

View File

@@ -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>