303 lines
9.4 KiB
TypeScript
303 lines
9.4 KiB
TypeScript
import { getServiceSupabase } from "@/lib/supabase/client";
|
|
import { Task } from "./tasks";
|
|
|
|
export interface Project {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
color: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface Sprint {
|
|
id: string;
|
|
name: string;
|
|
status: "planning" | "active" | "completed" | "cancelled";
|
|
startDate: string;
|
|
endDate: string;
|
|
projectId: string;
|
|
goal?: string;
|
|
}
|
|
|
|
export interface ProjectStats {
|
|
project: Project;
|
|
totalTasks: number;
|
|
completedTasks: number;
|
|
inProgressTasks: number;
|
|
urgentTasks: number;
|
|
highPriorityTasks: number;
|
|
progress: number;
|
|
recentTasks: Task[];
|
|
}
|
|
|
|
function toNonEmptyString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
}
|
|
|
|
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(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all projects from Supabase
|
|
*/
|
|
export async function fetchAllProjects(): Promise<Project[]> {
|
|
const supabase = getServiceSupabase();
|
|
const { data, error } = await supabase
|
|
.from("projects")
|
|
.select("*")
|
|
.order("created_at", { ascending: true });
|
|
|
|
if (error) {
|
|
console.error("Error fetching projects:", error);
|
|
throw new Error(`Failed to fetch projects: ${error.message}`);
|
|
}
|
|
|
|
return (data || []).map((row) => mapProjectRow(row as Record<string, unknown>));
|
|
}
|
|
|
|
/**
|
|
* Count total projects
|
|
*/
|
|
export async function countProjects(): Promise<number> {
|
|
const supabase = getServiceSupabase();
|
|
const { count, error } = await supabase
|
|
.from("projects")
|
|
.select("*", { count: "exact", head: true });
|
|
|
|
if (error) {
|
|
console.error("Error counting projects:", error);
|
|
return 0;
|
|
}
|
|
|
|
return count || 0;
|
|
}
|
|
|
|
// Sprint status type
|
|
const SPRINT_STATUSES = ["planning", "active", "completed", "cancelled"] as const;
|
|
type SprintStatus = typeof SPRINT_STATUSES[number];
|
|
|
|
function isSprintStatus(value: unknown): value is SprintStatus {
|
|
return typeof value === "string" && SPRINT_STATUSES.includes(value as SprintStatus);
|
|
}
|
|
|
|
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
|
return {
|
|
id: String(row.id ?? ""),
|
|
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
|
|
status: isSprintStatus(row.status) ? row.status : "planning",
|
|
startDate: toNonEmptyString(row.start_date) ?? new Date().toISOString(),
|
|
endDate: toNonEmptyString(row.end_date) ?? new Date().toISOString(),
|
|
projectId: String(row.project_id ?? ""),
|
|
goal: toNonEmptyString(row.goal),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all sprints from Supabase
|
|
*/
|
|
export async function fetchAllSprints(): Promise<Sprint[]> {
|
|
const supabase = getServiceSupabase();
|
|
const { data, error } = await supabase
|
|
.from("sprints")
|
|
.select("*")
|
|
.order("start_date", { ascending: false });
|
|
|
|
if (error) {
|
|
console.error("Error fetching sprints:", error);
|
|
return [];
|
|
}
|
|
|
|
return (data || []).map((row) => mapSprintRow(row as Record<string, unknown>));
|
|
}
|
|
|
|
/**
|
|
* Fetch sprints for a specific project
|
|
*/
|
|
export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
|
|
const supabase = getServiceSupabase();
|
|
const { data, error } = await supabase
|
|
.from("sprints")
|
|
.select("*")
|
|
.eq("project_id", projectId)
|
|
.order("start_date", { ascending: false });
|
|
|
|
if (error) {
|
|
console.error("Error fetching project sprints:", error);
|
|
return [];
|
|
}
|
|
|
|
return (data || []).map((row) => mapSprintRow(row as Record<string, unknown>));
|
|
}
|
|
|
|
/**
|
|
* Fetch active sprint
|
|
*/
|
|
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
|
const supabase = getServiceSupabase();
|
|
const { data, error } = await supabase
|
|
.from("sprints")
|
|
.select("*")
|
|
.eq("status", "active")
|
|
.maybeSingle();
|
|
|
|
if (error) {
|
|
console.error("Error fetching active sprint:", error);
|
|
return null;
|
|
}
|
|
|
|
return data ? mapSprintRow(data as Record<string, unknown>) : null;
|
|
}
|
|
|
|
/**
|
|
* Count sprints by status
|
|
*/
|
|
export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
const { data, error } = await supabase
|
|
.from("sprints")
|
|
.select("status");
|
|
|
|
if (error) {
|
|
console.error("Error counting sprints:", error);
|
|
return { planning: 0, active: 0, completed: 0, total: 0 };
|
|
}
|
|
|
|
const counts = { planning: 0, active: 0, completed: 0, total: 0 };
|
|
|
|
for (const row of data || []) {
|
|
counts.total++;
|
|
const status = row.status;
|
|
if (status === "planning") counts.planning++;
|
|
else if (status === "active") counts.active++;
|
|
else if (status === "completed") counts.completed++;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Calculate project statistics
|
|
*/
|
|
export function calculateProjectStats(project: Project, tasks: Task[]): ProjectStats {
|
|
const projectTasks = tasks.filter(t => t.projectId === project.id);
|
|
const completedTasks = projectTasks.filter(t => t.status === "done");
|
|
const inProgressTasks = projectTasks.filter(t => t.status === "in-progress");
|
|
const urgentTasks = projectTasks.filter(t => t.priority === "urgent" && t.status !== "done");
|
|
const highPriorityTasks = projectTasks.filter(t => t.priority === "high" && t.status !== "done");
|
|
|
|
const totalTasks = projectTasks.length;
|
|
const progress = totalTasks > 0 ? Math.round((completedTasks.length / totalTasks) * 100) : 0;
|
|
|
|
// Get recent tasks (last 5 updated)
|
|
const recentTasks = [...projectTasks]
|
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
.slice(0, 5);
|
|
|
|
return {
|
|
project,
|
|
totalTasks,
|
|
completedTasks: completedTasks.length,
|
|
inProgressTasks: inProgressTasks.length,
|
|
urgentTasks: urgentTasks.length,
|
|
highPriorityTasks: highPriorityTasks.length,
|
|
progress,
|
|
recentTasks,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all projects with their statistics
|
|
*/
|
|
export async function fetchProjectsWithStats(): Promise<ProjectStats[]> {
|
|
const supabase = getServiceSupabase();
|
|
|
|
// Fetch projects and tasks in parallel
|
|
const [projectsResult, tasksResult] = await Promise.all([
|
|
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
|
supabase.from("tasks").select("*"),
|
|
]);
|
|
|
|
if (projectsResult.error) {
|
|
console.error("Error fetching projects:", projectsResult.error);
|
|
throw new Error(`Failed to fetch projects: ${projectsResult.error.message}`);
|
|
}
|
|
|
|
const projects = (projectsResult.data || []).map((row) => mapProjectRow(row as Record<string, unknown>));
|
|
const tasks = (tasksResult.data || []).map((row) => {
|
|
const fallbackDate = new Date().toISOString();
|
|
return {
|
|
id: String(row.id ?? ""),
|
|
title: toNonEmptyString(row.title) ?? "",
|
|
description: toNonEmptyString(row.description),
|
|
type: (toNonEmptyString(row.type) as Task["type"]) ?? "task",
|
|
status: (toNonEmptyString(row.status) as Task["status"]) ?? "todo",
|
|
priority: (toNonEmptyString(row.priority) as Task["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: toNonEmptyString(row.created_by_id),
|
|
createdByName: toNonEmptyString(row.created_by_name),
|
|
assigneeId: toNonEmptyString(row.assignee_id),
|
|
assigneeName: toNonEmptyString(row.assignee_name),
|
|
dueDate: toNonEmptyString(row.due_date),
|
|
comments: Array.isArray(row.comments) ? row.comments : [],
|
|
tags: Array.isArray(row.tags) ? row.tags.filter((tag: unknown): tag is string => typeof tag === "string") : [],
|
|
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
|
} as Task;
|
|
});
|
|
|
|
return projects.map(project => calculateProjectStats(project, tasks));
|
|
}
|
|
|
|
/**
|
|
* Get project health status based on various metrics
|
|
*/
|
|
export function getProjectHealth(stats: ProjectStats): {
|
|
status: "healthy" | "warning" | "critical";
|
|
label: string;
|
|
color: string;
|
|
} {
|
|
const { progress, urgentTasks, highPriorityTasks, totalTasks } = stats;
|
|
|
|
// Critical: Has urgent tasks or no progress with many open tasks
|
|
if (urgentTasks > 0 || (totalTasks > 5 && progress === 0)) {
|
|
return { status: "critical", label: "Needs Attention", color: "text-red-500" };
|
|
}
|
|
|
|
// Warning: Has high priority tasks or low progress
|
|
if (highPriorityTasks > 2 || (totalTasks > 10 && progress < 30)) {
|
|
return { status: "warning", label: "At Risk", color: "text-yellow-500" };
|
|
}
|
|
|
|
// Healthy: Good progress, no urgent issues
|
|
return { status: "healthy", label: "On Track", color: "text-green-500" };
|
|
}
|
|
|
|
/**
|
|
* Calculate days remaining in sprint
|
|
*/
|
|
export function getSprintDaysRemaining(sprint: Sprint): number {
|
|
const endDate = new Date(sprint.endDate);
|
|
const today = new Date();
|
|
const diffTime = endDate.getTime() - today.getTime();
|
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
/**
|
|
* Format sprint date range
|
|
*/
|
|
export function formatSprintDateRange(sprint: Sprint): string {
|
|
const start = new Date(sprint.startDate);
|
|
const end = new Date(sprint.endDate);
|
|
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
|
|
}
|