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;
|
||||
}
|
||||
|
||||
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
|
||||
|
||||
export interface Recurrence {
|
||||
frequency: RecurrenceFrequency;
|
||||
/** Auto-activate the next instance (vs. queue it) */
|
||||
autoActivate?: boolean;
|
||||
}
|
||||
|
||||
// ─── Projects ───
|
||||
|
||||
export interface ProjectLink {
|
||||
@@ -84,6 +92,7 @@ export const tasks = pgTable("tasks", {
|
||||
dueDate: timestamp("due_date", { withTimezone: true }),
|
||||
estimatedHours: integer("estimated_hours"),
|
||||
tags: jsonb("tags").$type<string[]>().default([]),
|
||||
recurrence: jsonb("recurrence").$type<Recurrence>(),
|
||||
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
|
||||
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
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 { 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
|
||||
const statusOrder = sql`CASE
|
||||
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,
|
||||
estimatedHours: body.estimatedHours ?? null,
|
||||
tags: body.tags || [],
|
||||
recurrence: body.recurrence || null,
|
||||
subtasks: [],
|
||||
progressNotes: [],
|
||||
})
|
||||
@@ -197,6 +272,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||
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.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
|
||||
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.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
|
||||
|
||||
@@ -319,6 +402,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
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];
|
||||
},
|
||||
{
|
||||
@@ -336,6 +426,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
||||
dueDate: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
|
||||
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({
|
||||
id: t.String(),
|
||||
title: t.String(),
|
||||
|
||||
@@ -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