Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
ff3cc87dc8
commit
69984f7d86
@ -1,4 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||||
import { getAuthenticatedUser } from "@/lib/server/auth";
|
import { getAuthenticatedUser } from "@/lib/server/auth";
|
||||||
|
|
||||||
@ -31,6 +32,187 @@ interface Task {
|
|||||||
attachments?: unknown[];
|
attachments?: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Sprint {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
goal?: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: "planning" | "active" | "completed";
|
||||||
|
projectId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_TYPES: Task["type"][] = ["idea", "task", "bug", "research", "plan"];
|
||||||
|
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
||||||
|
const TASK_PRIORITIES: Task["priority"][] = ["low", "medium", "high", "urgent"];
|
||||||
|
const SPRINT_STATUSES: Sprint["status"][] = ["planning", "active", "completed"];
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function isTaskType(value: unknown): value is Task["type"] {
|
||||||
|
return typeof value === "string" && TASK_TYPES.includes(value as Task["type"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskStatus(value: unknown): value is Task["status"] {
|
||||||
|
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskPriority(value: unknown): value is Task["priority"] {
|
||||||
|
return typeof value === "string" && TASK_PRIORITIES.includes(value as Task["priority"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSprintStatus(value: unknown): value is Sprint["status"] {
|
||||||
|
return typeof value === "string" && SPRINT_STATUSES.includes(value as Sprint["status"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonEmptyString(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUuid(value: string | undefined): value is string {
|
||||||
|
return typeof value === "string" && UUID_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUndefined<T extends Record<string, unknown>>(value: T): Record<string, unknown> {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMissingColumnFromError(error: unknown): string | null {
|
||||||
|
if (!error || typeof error !== "object") return null;
|
||||||
|
const candidate = error as { code?: string; message?: string };
|
||||||
|
if (candidate.code !== "PGRST204" || typeof candidate.message !== "string") return null;
|
||||||
|
const match = candidate.message.match(/Could not find the '([^']+)' column/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForeignKeyColumnFromError(error: unknown): string | null {
|
||||||
|
if (!error || typeof error !== "object") return null;
|
||||||
|
const candidate = error as { code?: string; details?: string };
|
||||||
|
if (candidate.code !== "23503" || typeof candidate.details !== "string") return null;
|
||||||
|
const match = candidate.details.match(/\(([^)]+)\)=/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRequiredProjectId(
|
||||||
|
supabase: ReturnType<typeof getServiceSupabase>,
|
||||||
|
requestedProjectId?: string
|
||||||
|
): Promise<string> {
|
||||||
|
const requested = toNonEmptyString(requestedProjectId);
|
||||||
|
if (requested) {
|
||||||
|
const { data } = await supabase.from("projects").select("id").eq("id", requested).maybeSingle();
|
||||||
|
if (data?.id) return data.id as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: firstProject } = await supabase
|
||||||
|
.from("projects")
|
||||||
|
.select("id")
|
||||||
|
.order("created_at", { ascending: true })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (firstProject?.id) return firstProject.id as string;
|
||||||
|
throw new Error("No projects available to assign task");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOptionalForeignId(
|
||||||
|
supabase: ReturnType<typeof getServiceSupabase>,
|
||||||
|
table: "sprints" | "users",
|
||||||
|
id?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const requested = toNonEmptyString(id);
|
||||||
|
if (!requested) return null;
|
||||||
|
const { data } = await supabase.from(table).select("id").eq("id", requested).maybeSingle();
|
||||||
|
return data?.id ? (data.id as string) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRow(row: Record<string, unknown>): Project {
|
||||||
|
return {
|
||||||
|
id: String(row.id ?? ""),
|
||||||
|
name: toNonEmptyString(row.name) ?? "Untitled Project",
|
||||||
|
description: toNonEmptyString(row.description),
|
||||||
|
color: toNonEmptyString(row.color) ?? "#3b82f6",
|
||||||
|
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
||||||
|
const fallbackDate = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
id: String(row.id ?? ""),
|
||||||
|
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
|
||||||
|
goal: toNonEmptyString(row.goal),
|
||||||
|
startDate: toNonEmptyString(row.start_date) ?? fallbackDate,
|
||||||
|
endDate: toNonEmptyString(row.end_date) ?? fallbackDate,
|
||||||
|
status: isSprintStatus(row.status) ? row.status : "planning",
|
||||||
|
projectId: String(row.project_id ?? ""),
|
||||||
|
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapUserRow(row: Record<string, unknown>): UserProfile | null {
|
||||||
|
const id = toNonEmptyString(row.id);
|
||||||
|
const name = toNonEmptyString(row.name);
|
||||||
|
if (!id || !name) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
email: toNonEmptyString(row.email),
|
||||||
|
avatarUrl: toNonEmptyString(row.avatar_url),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTaskRow(row: Record<string, unknown>, usersById: Map<string, UserProfile>): Task {
|
||||||
|
const fallbackDate = new Date().toISOString();
|
||||||
|
const createdById = toNonEmptyString(row.created_by_id);
|
||||||
|
const updatedById = toNonEmptyString(row.updated_by_id);
|
||||||
|
const assigneeId = toNonEmptyString(row.assignee_id);
|
||||||
|
const createdByUser = createdById ? usersById.get(createdById) : undefined;
|
||||||
|
const updatedByUser = updatedById ? usersById.get(updatedById) : undefined;
|
||||||
|
const assigneeUser = assigneeId ? usersById.get(assigneeId) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(row.id ?? ""),
|
||||||
|
title: toNonEmptyString(row.title) ?? "",
|
||||||
|
description: toNonEmptyString(row.description),
|
||||||
|
type: isTaskType(row.type) ? row.type : "task",
|
||||||
|
status: isTaskStatus(row.status) ? row.status : "todo",
|
||||||
|
priority: isTaskPriority(row.priority) ? row.priority : "medium",
|
||||||
|
projectId: String(row.project_id ?? ""),
|
||||||
|
sprintId: toNonEmptyString(row.sprint_id),
|
||||||
|
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
||||||
|
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
|
||||||
|
createdById,
|
||||||
|
createdByName: toNonEmptyString(row.created_by_name) ?? createdByUser?.name,
|
||||||
|
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url) ?? createdByUser?.avatarUrl,
|
||||||
|
updatedById,
|
||||||
|
updatedByName: toNonEmptyString(row.updated_by_name) ?? updatedByUser?.name,
|
||||||
|
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url) ?? updatedByUser?.avatarUrl,
|
||||||
|
assigneeId,
|
||||||
|
assigneeName: toNonEmptyString(row.assignee_name) ?? assigneeUser?.name,
|
||||||
|
assigneeEmail: toNonEmptyString(row.assignee_email) ?? assigneeUser?.email,
|
||||||
|
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url) ?? assigneeUser?.avatarUrl,
|
||||||
|
dueDate: toNonEmptyString(row.due_date),
|
||||||
|
comments: Array.isArray(row.comments) ? row.comments : [],
|
||||||
|
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
||||||
|
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// GET - fetch all tasks, projects, and sprints
|
// GET - fetch all tasks, projects, and sprints
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@ -41,17 +223,24 @@ export async function GET() {
|
|||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
|
|
||||||
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: meta }] = await Promise.all([
|
const [{ data: projects }, { data: sprints }, { data: tasks }, { data: users }, { data: meta }] = await Promise.all([
|
||||||
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
||||||
supabase.from("sprints").select("*").order("start_date", { ascending: true }),
|
supabase.from("sprints").select("*").order("start_date", { ascending: true }),
|
||||||
supabase.from("tasks").select("*").order("created_at", { ascending: true }),
|
supabase.from("tasks").select("*").order("created_at", { ascending: true }),
|
||||||
|
supabase.from("users").select("id, name, email, avatar_url"),
|
||||||
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
|
supabase.from("meta").select("value").eq("key", "lastUpdated").maybeSingle(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const usersById = new Map<string, UserProfile>();
|
||||||
|
for (const row of users || []) {
|
||||||
|
const mapped = mapUserRow(row as Record<string, unknown>);
|
||||||
|
if (mapped) usersById.set(mapped.id, mapped);
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
projects: projects || [],
|
projects: (projects || []).map((row) => mapProjectRow(row as Record<string, unknown>)),
|
||||||
sprints: sprints || [],
|
sprints: (sprints || []).map((row) => mapSprintRow(row as Record<string, unknown>)),
|
||||||
tasks: tasks || [],
|
tasks: (tasks || []).map((row) => mapTaskRow(row as Record<string, unknown>, usersById)),
|
||||||
lastUpdated: Number(meta?.value ?? Date.now()),
|
lastUpdated: Number(meta?.value ?? Date.now()),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -79,63 +268,74 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const supabase = getServiceSupabase();
|
const supabase = getServiceSupabase();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const clientTaskId = toNonEmptyString(task.id);
|
||||||
|
const canonicalTaskId = isUuid(clientTaskId) ? clientTaskId : randomUUID();
|
||||||
|
const resolvedProjectId = await resolveRequiredProjectId(supabase, task.projectId);
|
||||||
|
const resolvedSprintId = await resolveOptionalForeignId(supabase, "sprints", task.sprintId);
|
||||||
|
const resolvedAssigneeId = await resolveOptionalForeignId(supabase, "users", task.assigneeId);
|
||||||
|
|
||||||
// Check if task exists
|
// Check if task exists
|
||||||
const { data: existing } = await supabase
|
const { data: existing } = await supabase
|
||||||
.from("tasks")
|
.from("tasks")
|
||||||
.select("id")
|
.select("id")
|
||||||
.eq("id", task.id)
|
.eq("id", canonicalTaskId)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
const taskData = {
|
let taskData = stripUndefined({
|
||||||
id: task.id,
|
id: canonicalTaskId,
|
||||||
|
legacy_id: clientTaskId && !isUuid(clientTaskId) ? clientTaskId : null,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description || null,
|
description: task.description || null,
|
||||||
type: task.type || "task",
|
type: task.type || "task",
|
||||||
status: task.status || "todo",
|
status: task.status || "todo",
|
||||||
priority: task.priority || "medium",
|
priority: task.priority || "medium",
|
||||||
project_id: task.projectId,
|
project_id: resolvedProjectId,
|
||||||
sprint_id: task.sprintId || null,
|
sprint_id: resolvedSprintId,
|
||||||
created_at: existing ? undefined : (task.createdAt || now),
|
created_at: existing ? undefined : (task.createdAt || now),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
created_by_id: existing ? undefined : (task.createdById || user.id),
|
created_by_id: existing ? undefined : user.id,
|
||||||
created_by_name: existing ? undefined : (task.createdByName || user.name),
|
|
||||||
created_by_avatar_url: existing ? undefined : task.createdByAvatarUrl,
|
|
||||||
updated_by_id: user.id,
|
updated_by_id: user.id,
|
||||||
updated_by_name: user.name,
|
assignee_id: resolvedAssigneeId,
|
||||||
updated_by_avatar_url: user.avatarUrl,
|
|
||||||
assignee_id: task.assigneeId || null,
|
|
||||||
assignee_name: task.assigneeName || null,
|
|
||||||
assignee_email: task.assigneeEmail || null,
|
|
||||||
assignee_avatar_url: task.assigneeAvatarUrl || null,
|
|
||||||
due_date: task.dueDate || null,
|
due_date: task.dueDate || null,
|
||||||
comments: task.comments || [],
|
comments: task.comments || [],
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
attachments: task.attachments || [],
|
attachments: task.attachments || [],
|
||||||
};
|
});
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (existing) {
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||||
// Update existing
|
const query = existing
|
||||||
const { data, error } = await supabase
|
? supabase.from("tasks").update(taskData).eq("id", canonicalTaskId).select().single()
|
||||||
.from("tasks")
|
: supabase.from("tasks").insert(taskData).select().single();
|
||||||
.update(taskData)
|
|
||||||
.eq("id", task.id)
|
const { data, error } = await query;
|
||||||
.select()
|
if (!error) {
|
||||||
.single();
|
result = data;
|
||||||
if (error) throw error;
|
break;
|
||||||
result = data;
|
}
|
||||||
} else {
|
|
||||||
// Insert new
|
const missingColumn = getMissingColumnFromError(error);
|
||||||
const { data, error } = await supabase
|
if (missingColumn && missingColumn in taskData) {
|
||||||
.from("tasks")
|
const nextPayload = { ...taskData };
|
||||||
.insert(taskData)
|
delete nextPayload[missingColumn];
|
||||||
.select()
|
taskData = nextPayload;
|
||||||
.single();
|
console.warn(`>>> API POST: removing unsupported tasks column '${missingColumn}' and retrying`);
|
||||||
if (error) throw error;
|
continue;
|
||||||
result = data;
|
}
|
||||||
|
|
||||||
|
const foreignKeyColumn = getForeignKeyColumnFromError(error);
|
||||||
|
if (foreignKeyColumn && foreignKeyColumn in taskData) {
|
||||||
|
const nextPayload = { ...taskData, [foreignKeyColumn]: null };
|
||||||
|
taskData = nextPayload;
|
||||||
|
console.warn(`>>> API POST: clearing invalid foreign key '${foreignKeyColumn}' and retrying`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result) throw new Error("Failed to save task after schema fallback attempts");
|
||||||
|
|
||||||
// Update lastUpdated
|
// Update lastUpdated
|
||||||
await supabase.from("meta").upsert({
|
await supabase.from("meta").upsert({
|
||||||
key: "lastUpdated",
|
key: "lastUpdated",
|
||||||
@ -147,7 +347,15 @@ export async function POST(request: Request) {
|
|||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error(">>> API POST error:", error);
|
console.error(">>> API POST error:", error);
|
||||||
const message = error instanceof Error ? error.message : "Failed to save";
|
const message = error instanceof Error ? error.message : "Failed to save";
|
||||||
return NextResponse.json({ error: message }, { status: 500 });
|
const details =
|
||||||
|
error && typeof error === "object"
|
||||||
|
? {
|
||||||
|
code: "code" in error ? (error as { code?: unknown }).code : undefined,
|
||||||
|
details: "details" in error ? (error as { details?: unknown }).details : undefined,
|
||||||
|
hint: "hint" in error ? (error as { hint?: unknown }).hint : undefined,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
return NextResponse.json({ error: message, details }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,6 +42,14 @@ const typeLabels: Record<string, string> = {
|
|||||||
plan: "📐",
|
plan: "📐",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
||||||
|
if (!startDate || !endDate) return "No dates"
|
||||||
|
const start = parseISO(startDate)
|
||||||
|
const end = parseISO(endDate)
|
||||||
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||||
|
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
||||||
|
}
|
||||||
|
|
||||||
interface AssignableUser {
|
interface AssignableUser {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -380,12 +388,7 @@ export function BacklogView() {
|
|||||||
currentSprint
|
currentSprint
|
||||||
? {
|
? {
|
||||||
name: currentSprint.name,
|
name: currentSprint.name,
|
||||||
date: `${(() => {
|
date: formatSprintDateRange(currentSprint.startDate, currentSprint.endDate),
|
||||||
const start = parseISO(currentSprint.startDate)
|
|
||||||
const end = parseISO(currentSprint.endDate)
|
|
||||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
|
||||||
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
|
||||||
})()}`,
|
|
||||||
status: currentSprint.status,
|
status: currentSprint.status,
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
@ -410,12 +413,7 @@ export function BacklogView() {
|
|||||||
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
resolveAssigneeAvatar={resolveAssigneeAvatar}
|
||||||
sprintInfo={{
|
sprintInfo={{
|
||||||
name: sprint.name,
|
name: sprint.name,
|
||||||
date: (() => {
|
date: formatSprintDateRange(sprint.startDate, sprint.endDate),
|
||||||
const start = parseISO(sprint.startDate)
|
|
||||||
const end = parseISO(sprint.endDate)
|
|
||||||
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
|
||||||
return `${format(start, "MMM d")} - ${format(end, "MMM d")}`
|
|
||||||
})(),
|
|
||||||
status: sprint.status,
|
status: sprint.status,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import { Card, CardContent } from "@/components/ui/card"
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
import { Plus, Calendar, Flag, GripVertical } from "lucide-react"
|
||||||
import { format, parseISO } from "date-fns"
|
import { format, isValid, parseISO } from "date-fns"
|
||||||
|
|
||||||
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
const statusColumns = ["backlog", "in-progress", "review", "done"] as const
|
||||||
type SprintColumnStatus = typeof statusColumns[number]
|
type SprintColumnStatus = typeof statusColumns[number]
|
||||||
@ -48,6 +48,14 @@ const priorityColors: Record<string, string> = {
|
|||||||
urgent: "bg-red-600",
|
urgent: "bg-red-600",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSprintDateRange(startDate?: string, endDate?: string): string {
|
||||||
|
if (!startDate || !endDate) return "No dates"
|
||||||
|
const start = parseISO(startDate)
|
||||||
|
const end = parseISO(endDate)
|
||||||
|
if (!isValid(start) || !isValid(end)) return "Invalid dates"
|
||||||
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`
|
||||||
|
}
|
||||||
|
|
||||||
// Sortable Task Card Component
|
// Sortable Task Card Component
|
||||||
function SortableTaskCard({
|
function SortableTaskCard({
|
||||||
task,
|
task,
|
||||||
@ -347,10 +355,7 @@ export function SprintBoard() {
|
|||||||
<div className="flex items-center gap-4 text-sm text-slate-400">
|
<div className="flex items-center gap-4 text-sm text-slate-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
<span>
|
<span>{formatSprintDateRange(currentSprint.startDate, currentSprint.endDate)}</span>
|
||||||
{format(parseISO(currentSprint.startDate), "MMM d")} -{" "}
|
|
||||||
{format(parseISO(currentSprint.endDate), "MMM d, yyyy")}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
|
|||||||
@ -389,15 +389,8 @@ export async function saveData(data: DataStore): Promise<DataStore> {
|
|||||||
created_at: t.createdAt,
|
created_at: t.createdAt,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
created_by_id: t.createdById,
|
created_by_id: t.createdById,
|
||||||
created_by_name: t.createdByName,
|
|
||||||
created_by_avatar_url: t.createdByAvatarUrl,
|
|
||||||
updated_by_id: t.updatedById,
|
updated_by_id: t.updatedById,
|
||||||
updated_by_name: t.updatedByName,
|
|
||||||
updated_by_avatar_url: t.updatedByAvatarUrl,
|
|
||||||
assignee_id: t.assigneeId,
|
assignee_id: t.assigneeId,
|
||||||
assignee_name: t.assigneeName,
|
|
||||||
assignee_email: t.assigneeEmail,
|
|
||||||
assignee_avatar_url: t.assigneeAvatarUrl,
|
|
||||||
due_date: t.dueDate,
|
due_date: t.dueDate,
|
||||||
comments: t.comments,
|
comments: t.comments,
|
||||||
tags: t.tags,
|
tags: t.tags,
|
||||||
|
|||||||
@ -552,6 +552,13 @@ const removeCommentFromThread = (comments: Comment[], targetId: string): Comment
|
|||||||
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
|
replies: removeCommentFromThread(normalizeComments(comment.replies), targetId),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const generateTaskId = (): string => {
|
||||||
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to sync a single task to server (lightweight)
|
// Helper to sync a single task to server (lightweight)
|
||||||
async function syncTaskToServer(task: Task) {
|
async function syncTaskToServer(task: Task) {
|
||||||
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
|
console.log('>>> syncTaskToServer: saving task', task.id, task.title)
|
||||||
@ -565,7 +572,8 @@ async function syncTaskToServer(task: Task) {
|
|||||||
console.log('>>> syncTaskToServer: saved successfully')
|
console.log('>>> syncTaskToServer: saved successfully')
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
console.error('>>> syncTaskToServer: failed with status', res.status)
|
const errorPayload = await res.json().catch(() => null)
|
||||||
|
console.error('>>> syncTaskToServer: failed with status', res.status, errorPayload)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -721,7 +729,7 @@ export const useTaskStore = create<TaskStore>()(
|
|||||||
const actor = profileToCommentAuthor(get().currentUser)
|
const actor = profileToCommentAuthor(get().currentUser)
|
||||||
const newTask: Task = {
|
const newTask: Task = {
|
||||||
...task,
|
...task,
|
||||||
id: Date.now().toString(),
|
id: generateTaskId(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
createdById: actor.id,
|
createdById: actor.id,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user