mission-control/lib/data/projects.ts

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" })}`;
}