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:
@@ -46,6 +46,14 @@ export interface Subtask {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
|
||||||
|
|
||||||
|
export interface Recurrence {
|
||||||
|
frequency: RecurrenceFrequency;
|
||||||
|
/** Auto-activate the next instance (vs. queue it) */
|
||||||
|
autoActivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Projects ───
|
// ─── Projects ───
|
||||||
|
|
||||||
export interface ProjectLink {
|
export interface ProjectLink {
|
||||||
@@ -84,6 +92,7 @@ export const tasks = pgTable("tasks", {
|
|||||||
dueDate: timestamp("due_date", { withTimezone: true }),
|
dueDate: timestamp("due_date", { withTimezone: true }),
|
||||||
estimatedHours: integer("estimated_hours"),
|
estimatedHours: integer("estimated_hours"),
|
||||||
tags: jsonb("tags").$type<string[]>().default([]),
|
tags: jsonb("tags").$type<string[]>().default([]),
|
||||||
|
recurrence: jsonb("recurrence").$type<Recurrence>(),
|
||||||
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
|
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
|
||||||
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { tasks, type ProgressNote, type Subtask } from "../db/schema";
|
import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema";
|
||||||
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
|
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
|
||||||
import { auth } from "../lib/auth";
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
@@ -41,6 +41,80 @@ async function notifyTaskActivated(task: { id: string; title: string; descriptio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute the next due date for a recurring task
|
||||||
|
function computeNextDueDate(frequency: RecurrenceFrequency, fromDate?: Date | null): Date {
|
||||||
|
const base = fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
|
||||||
|
switch (frequency) {
|
||||||
|
case "daily":
|
||||||
|
base.setDate(base.getDate() + 1);
|
||||||
|
break;
|
||||||
|
case "weekly":
|
||||||
|
base.setDate(base.getDate() + 7);
|
||||||
|
break;
|
||||||
|
case "biweekly":
|
||||||
|
base.setDate(base.getDate() + 14);
|
||||||
|
break;
|
||||||
|
case "monthly":
|
||||||
|
base.setMonth(base.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the next instance of a recurring task
|
||||||
|
async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
|
||||||
|
const recurrence = completedTask.recurrence as Recurrence | null;
|
||||||
|
if (!recurrence) return;
|
||||||
|
|
||||||
|
// Get next task number
|
||||||
|
const maxNum = await db
|
||||||
|
.select({ max: sql<number>`COALESCE(MAX(${tasks.taskNumber}), 0)` })
|
||||||
|
.from(tasks);
|
||||||
|
const nextNumber = (maxNum[0]?.max ?? 0) + 1;
|
||||||
|
|
||||||
|
const maxPos = await db
|
||||||
|
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
|
||||||
|
.from(tasks);
|
||||||
|
|
||||||
|
const nextDue = computeNextDueDate(recurrence.frequency, completedTask.dueDate);
|
||||||
|
const nextStatus = recurrence.autoActivate ? "active" : "queued";
|
||||||
|
|
||||||
|
const newTask = await db
|
||||||
|
.insert(tasks)
|
||||||
|
.values({
|
||||||
|
title: completedTask.title,
|
||||||
|
description: completedTask.description,
|
||||||
|
source: completedTask.source,
|
||||||
|
status: nextStatus,
|
||||||
|
priority: completedTask.priority,
|
||||||
|
position: (maxPos[0]?.max ?? 0) + 1,
|
||||||
|
taskNumber: nextNumber,
|
||||||
|
assigneeId: completedTask.assigneeId,
|
||||||
|
assigneeName: completedTask.assigneeName,
|
||||||
|
projectId: completedTask.projectId,
|
||||||
|
dueDate: nextDue,
|
||||||
|
estimatedHours: completedTask.estimatedHours,
|
||||||
|
tags: completedTask.tags,
|
||||||
|
recurrence: recurrence,
|
||||||
|
subtasks: (completedTask.subtasks as Subtask[] || []).map(s => ({
|
||||||
|
...s,
|
||||||
|
completed: false,
|
||||||
|
completedAt: undefined,
|
||||||
|
})),
|
||||||
|
progressNotes: [],
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
console.log(`[recurrence] Spawned next instance: ${newTask[0].id} (HQ-${nextNumber}) due ${nextDue.toISOString()} from completed task ${completedTask.id}`);
|
||||||
|
|
||||||
|
// If auto-activate, fire the webhook
|
||||||
|
if (nextStatus === "active") {
|
||||||
|
notifyTaskActivated(newTask[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newTask[0];
|
||||||
|
}
|
||||||
|
|
||||||
// Status sort order: active first, then queued, blocked, completed, cancelled
|
// Status sort order: active first, then queued, blocked, completed, cancelled
|
||||||
const statusOrder = sql`CASE
|
const statusOrder = sql`CASE
|
||||||
WHEN ${tasks.status} = 'active' THEN 0
|
WHEN ${tasks.status} = 'active' THEN 0
|
||||||
@@ -156,6 +230,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
dueDate: body.dueDate ? new Date(body.dueDate) : null,
|
||||||
estimatedHours: body.estimatedHours ?? null,
|
estimatedHours: body.estimatedHours ?? null,
|
||||||
tags: body.tags || [],
|
tags: body.tags || [],
|
||||||
|
recurrence: body.recurrence || null,
|
||||||
subtasks: [],
|
subtasks: [],
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
})
|
})
|
||||||
@@ -197,6 +272,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
tags: t.Optional(t.Array(t.String())),
|
tags: t.Optional(t.Array(t.String())),
|
||||||
|
recurrence: t.Optional(t.Union([
|
||||||
|
t.Object({
|
||||||
|
frequency: t.Union([t.Literal("daily"), t.Literal("weekly"), t.Literal("biweekly"), t.Literal("monthly")]),
|
||||||
|
autoActivate: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
t.Null(),
|
||||||
|
])),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -304,6 +386,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||||
if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
|
if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
|
||||||
if (body.tags !== undefined) updates.tags = body.tags;
|
if (body.tags !== undefined) updates.tags = body.tags;
|
||||||
|
if (body.recurrence !== undefined) updates.recurrence = body.recurrence;
|
||||||
if (body.subtasks !== undefined) updates.subtasks = body.subtasks;
|
if (body.subtasks !== undefined) updates.subtasks = body.subtasks;
|
||||||
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
|
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
|
||||||
|
|
||||||
@@ -319,6 +402,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
notifyTaskActivated(updated[0]);
|
notifyTaskActivated(updated[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spawn next recurring instance if task was just completed
|
||||||
|
if (body.status === "completed" && updated[0].recurrence) {
|
||||||
|
spawnNextRecurrence(updated[0]).catch((err) =>
|
||||||
|
console.error("Failed to spawn recurring task:", err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return updated[0];
|
return updated[0];
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -336,6 +426,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||||
tags: t.Optional(t.Array(t.String())),
|
tags: t.Optional(t.Array(t.String())),
|
||||||
|
recurrence: t.Optional(t.Union([
|
||||||
|
t.Object({
|
||||||
|
frequency: t.Union([t.Literal("daily"), t.Literal("weekly"), t.Literal("biweekly"), t.Literal("monthly")]),
|
||||||
|
autoActivate: t.Optional(t.Boolean()),
|
||||||
|
}),
|
||||||
|
t.Null(),
|
||||||
|
])),
|
||||||
subtasks: t.Optional(t.Array(t.Object({
|
subtasks: t.Optional(t.Array(t.Object({
|
||||||
id: t.String(),
|
id: t.String(),
|
||||||
title: t.String(),
|
title: t.String(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { fetchProjects } from "../lib/api";
|
import { fetchProjects } from "../lib/api";
|
||||||
import type { Project } from "../lib/types";
|
import type { Project, RecurrenceFrequency, Recurrence } from "../lib/types";
|
||||||
|
|
||||||
interface CreateTaskModalProps {
|
interface CreateTaskModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,6 +13,7 @@ interface CreateTaskModalProps {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
estimatedHours?: number;
|
estimatedHours?: number;
|
||||||
|
recurrence?: Recurrence | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
const [dueDate, setDueDate] = useState("");
|
const [dueDate, setDueDate] = useState("");
|
||||||
const [estimatedHours, setEstimatedHours] = useState("");
|
const [estimatedHours, setEstimatedHours] = useState("");
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [recurrenceFreq, setRecurrenceFreq] = useState<RecurrenceFrequency | "">("");
|
||||||
|
const [recurrenceAutoActivate, setRecurrenceAutoActivate] = useState(false);
|
||||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,6 +59,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
projectId: projectId || undefined,
|
projectId: projectId || undefined,
|
||||||
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
|
||||||
estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
|
estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
|
||||||
|
recurrence: recurrenceFreq ? { frequency: recurrenceFreq, autoActivate: recurrenceAutoActivate } : undefined,
|
||||||
});
|
});
|
||||||
// Reset form
|
// Reset form
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -65,6 +69,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
setProjectId("");
|
setProjectId("");
|
||||||
setDueDate("");
|
setDueDate("");
|
||||||
setEstimatedHours("");
|
setEstimatedHours("");
|
||||||
|
setRecurrenceFreq("");
|
||||||
|
setRecurrenceAutoActivate(false);
|
||||||
setShowAdvanced(false);
|
setShowAdvanced(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -200,6 +206,38 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Source */}
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label>
|
<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}
|
🏷️ {tag}
|
||||||
</span>
|
</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 && (
|
{task.estimatedHours != null && task.estimatedHours > 0 && (
|
||||||
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
⏱ {task.estimatedHours}h
|
⏱ {task.estimatedHours}h
|
||||||
|
|||||||
@@ -133,6 +133,11 @@ export function TaskCard({
|
|||||||
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
|
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
|
||||||
{timeAgo(task.createdAt)}
|
{timeAgo(task.createdAt)}
|
||||||
</span>
|
</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 && (
|
{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">
|
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
|
||||||
⏱ {task.estimatedHours}h
|
⏱ {task.estimatedHours}h
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
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 { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
|
||||||
import { useToast } from "./Toast";
|
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 [draftEstimatedHours, setDraftEstimatedHours] = useState<string>(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||||
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
|
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
|
||||||
const [tagInput, setTagInput] = useState("");
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [draftRecurrence, setDraftRecurrence] = useState<Recurrence | null>(task.recurrence || null);
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
|
||||||
const [addingSubtask, setAddingSubtask] = useState(false);
|
const [addingSubtask, setAddingSubtask] = useState(false);
|
||||||
@@ -287,7 +288,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
setDraftAssigneeName(task.assigneeName || "");
|
setDraftAssigneeName(task.assigneeName || "");
|
||||||
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||||
setDraftTags(task.tags || []);
|
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 currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
|
||||||
const isDirty =
|
const isDirty =
|
||||||
@@ -299,7 +301,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
draftDueDate !== currentDueDate ||
|
draftDueDate !== currentDueDate ||
|
||||||
draftAssigneeName !== (task.assigneeName || "") ||
|
draftAssigneeName !== (task.assigneeName || "") ||
|
||||||
draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "") ||
|
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 = () => {
|
const handleCancel = () => {
|
||||||
setDraftTitle(task.title);
|
setDraftTitle(task.title);
|
||||||
@@ -311,6 +314,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
setDraftAssigneeName(task.assigneeName || "");
|
setDraftAssigneeName(task.assigneeName || "");
|
||||||
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
|
||||||
setDraftTags(task.tags || []);
|
setDraftTags(task.tags || []);
|
||||||
|
setDraftRecurrence(task.recurrence || null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
@@ -329,6 +333,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
(updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null;
|
(updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null;
|
||||||
}
|
}
|
||||||
if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags;
|
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);
|
await updateTask(task.id, updates, token);
|
||||||
onTaskUpdated();
|
onTaskUpdated();
|
||||||
toast("Changes saved", "success");
|
toast("Changes saved", "success");
|
||||||
@@ -672,6 +677,62 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Description */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
|
<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>
|
<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";
|
const BASE = "/api/tasks";
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(
|
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
|
token?: string
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export interface ProjectWithTasks extends Project {
|
|||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
|
||||||
|
|
||||||
|
export interface Recurrence {
|
||||||
|
frequency: RecurrenceFrequency;
|
||||||
|
autoActivate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
taskNumber: number;
|
taskNumber: number;
|
||||||
@@ -59,6 +66,7 @@ export interface Task {
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
estimatedHours: number | null;
|
estimatedHours: number | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
recurrence: Recurrence | null;
|
||||||
subtasks: Subtask[];
|
subtasks: Subtask[];
|
||||||
progressNotes: ProgressNote[];
|
progressNotes: ProgressNote[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -255,6 +255,9 @@ export function DashboardPage() {
|
|||||||
{task.assigneeName && (
|
{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>
|
<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 && (() => {
|
{task.dueDate && (() => {
|
||||||
const due = new Date(task.dueDate);
|
const due = new Date(task.dueDate);
|
||||||
const diffMs = due.getTime() - Date.now();
|
const diffMs = due.getTime() - Date.now();
|
||||||
|
|||||||
@@ -248,6 +248,11 @@ export function TaskPage() {
|
|||||||
📁 {project.name}
|
📁 {project.name}
|
||||||
</span>
|
</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 => (
|
{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">
|
<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}
|
🏷️ {tag}
|
||||||
@@ -623,6 +628,14 @@ export function TaskPage() {
|
|||||||
<span className="text-gray-700 dark:text-gray-300 font-medium">⏱ {task.estimatedHours}h</span>
|
<span className="text-gray-700 dark:text-gray-300 font-medium">⏱ {task.estimatedHours}h</span>
|
||||||
</div>
|
</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 && (
|
{task.tags?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>
|
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user