Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-24 20:22:44 -06:00
parent 865c11dd8b
commit 9ce8deb678
16 changed files with 576 additions and 688 deletions

17
.env.example Normal file
View File

@ -0,0 +1,17 @@
# Mission Control Environment Variables
# Copy to .env.local for local development.
# Core Supabase config (required)
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-public-key-here
SUPABASE_SERVICE_ROLE_KEY=your-service-role-secret-key-here
# Gantt-board API integration (required when consuming gantt-board API)
# Docker example: http://gantt-board:3000/api
GANTT_API_BASE_URL=http://localhost:3000/api
GANTT_API_BEARER_TOKEN=replace_with_same_value_as_gantt_machine_token
# Optional link targets for UI
NEXT_PUBLIC_GANTT_BOARD_URL=http://localhost:3000
NEXT_PUBLIC_MISSION_CONTROL_URL=http://localhost:3001
NEXT_PUBLIC_BLOG_BACKUP_URL=http://localhost:3002

2
.gitignore vendored
View File

@ -33,6 +33,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
!.env.example
!.env.local.example
# vercel # vercel
.vercel .vercel

View File

@ -137,6 +137,7 @@ mission-control/
NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY= SUPABASE_SERVICE_ROLE_KEY=
GANTT_API_BASE_URL= # ex: http://gantt-board:3000/api (Docker network)
# Optional # Optional
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
@ -150,6 +151,9 @@ NEXT_PUBLIC_SUPABASE_SITE_URL=
NEXT_PUBLIC_GOOGLE_URL= NEXT_PUBLIC_GOOGLE_URL=
NEXT_PUBLIC_GOOGLE_CALENDAR_URL= NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL= NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
NEXT_PUBLIC_GANTT_API_BASE_URL= # Client-side override if needed
GANTT_API_BEARER_TOKEN= # Optional server-to-server auth header
GANTT_API_COOKIE= # Optional server-to-server cookie header
``` ```
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above. Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
@ -170,6 +174,30 @@ npm run build
npm start npm start
``` ```
## CLI Reuse (API Passthrough)
Mission Control reuses the existing task/project CLI from the sibling `gantt-board` project instead of duplicating command logic.
- `scripts/task.sh` delegates to `../gantt-board/scripts/task.sh`
- `scripts/project.sh` delegates to `../gantt-board/scripts/project.sh`
- `scripts/update-task-status.js` delegates to `gantt-board` `task.sh update`
Environment variables:
- `GANTT_BOARD_DIR` (optional): override path to the `gantt-board` repo root.
- `API_URL` (optional): forwarded to reused CLI so all business logic stays in API endpoints.
Mission Control web data for tasks/projects/sprints is also read from `gantt-board` API via `GANTT_API_BASE_URL`, so both CLI and web use the same backend contract.
For separate Docker images, set `GANTT_API_BASE_URL` to the Docker service DNS name (example: `http://gantt-board:3000/api`) on the same Docker network.
If `gantt-board` API auth is required, provide either `GANTT_API_COOKIE` (session cookie) or `GANTT_API_BEARER_TOKEN` as server-to-server auth context.
Run passthrough contract test:
```bash
npm run test:cli-contract
```
## Deployment ## Deployment
The app deploys automatically to Vercel on push to main: The app deploys automatically to Vercel on push to main:

42
app/podcast/page.tsx Normal file
View File

@ -0,0 +1,42 @@
export default function PodcastPage() {
return (
<div className="min-h-screen bg-background p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-4">OpenClaw Daily Digest Podcast</h1>
<p className="text-muted-foreground mb-8">
Daily tech news and insights for developers, delivered as a podcast.
</p>
<div className="bg-card rounded-lg p-6 mb-8">
<h2 className="text-xl font-semibold mb-4">Subscribe</h2>
<div className="space-y-3">
<a
href="/podcast/rss.xml"
className="flex items-center gap-3 p-3 bg-primary/10 rounded-lg hover:bg-primary/20 transition-colors"
>
<span className="text-2xl">🎧</span>
<div>
<p className="font-medium">RSS Feed</p>
<p className="text-sm text-muted-foreground">Copy URL to any podcast app</p>
</div>
</a>
</div>
</div>
<div className="bg-card rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">About</h2>
<ul className="space-y-2 text-muted-foreground">
<li> Daily episodes (~5 minutes)</li>
<li> iOS, AI, coding tools, and entrepreneurship news</li>
<li> Generated using AI text-to-speech</li>
<li> Available every morning at 7 AM CST</li>
</ul>
</div>
<div className="mt-8 text-center text-sm text-muted-foreground">
<p>New episodes are generated automatically from the daily digest.</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { readFileSync, existsSync } from "fs";
import { join } from "path";
const PODCAST_DIR = "/Users/mattbruce/.openclaw/workspace/podcast";
const RSS_FILE = join(PODCAST_DIR, "rss.xml");
export async function GET() {
try {
// Check if RSS file exists
if (!existsSync(RSS_FILE)) {
// Return a default RSS feed if none exists yet
const defaultFeed = `<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" version="2.0">
<channel>
<title>OpenClaw Daily Digest</title>
<link>https://mission-control-rho-pink.vercel.app/podcast</link>
<language>en-us</language>
<copyright>© 2026 OpenClaw</copyright>
<itunes:author>OpenClaw</itunes:author>
<description>Daily tech news and insights for developers</description>
<itunes:image href="https://mission-control-rho-pink.vercel.app/podcast-cover.jpg"/>
<itunes:category text="Technology"/>
<itunes:explicit>no</itunes:explicit>
<item>
<title>Welcome to OpenClaw Daily Digest Podcast</title>
<description>Your daily tech news podcast is coming soon. Check back for the first episode!</description>
<pubDate>${new Date().toUTCString()}</pubDate>
<guid isPermaLink="false">welcome</guid>
</item>
</channel>
</rss>`;
return new NextResponse(defaultFeed, {
headers: {
"Content-Type": "application/rss+xml",
"Cache-Control": "public, max-age=3600",
},
});
}
// Read and serve the RSS file
const rssContent = readFileSync(RSS_FILE, "utf-8");
return new NextResponse(rssContent, {
headers: {
"Content-Type": "application/rss+xml",
"Cache-Control": "public, max-age=3600",
},
});
} catch (error) {
console.error("Error serving RSS feed:", error);
return NextResponse.json(
{ error: "Failed to load RSS feed" },
{ status: 500 }
);
}
}

50
lib/data/gantt-api.ts Normal file
View File

@ -0,0 +1,50 @@
const DEFAULT_GANTT_API_BASE_URL = "http://localhost:3000/api";
function normalizeBaseUrl(url: string): string {
return url.replace(/\/+$/, "");
}
export function getGanttApiBaseUrl(): string {
const configured =
process.env.GANTT_API_BASE_URL ||
process.env.NEXT_PUBLIC_GANTT_API_BASE_URL ||
DEFAULT_GANTT_API_BASE_URL;
return normalizeBaseUrl(configured);
}
function buildAuthHeaders(): HeadersInit {
const headers: Record<string, string> = {};
if (process.env.GANTT_API_BEARER_TOKEN) {
headers.Authorization = `Bearer ${process.env.GANTT_API_BEARER_TOKEN}`;
}
if (process.env.GANTT_API_COOKIE) {
headers.Cookie = process.env.GANTT_API_COOKIE;
}
return headers;
}
export async function fetchGanttApi<T>(endpoint: string): Promise<T> {
const baseUrl = getGanttApiBaseUrl();
const response = await fetch(`${baseUrl}${endpoint}`, {
method: "GET",
cache: "no-store",
headers: {
Accept: "application/json",
...buildAuthHeaders(),
},
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; message?: string }
| null;
if (!response.ok) {
const details = payload?.error || payload?.message || response.statusText;
throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): ${details}`);
}
return payload as T;
}

View File

@ -1,11 +1,6 @@
import { getServiceSupabase } from "@/lib/supabase/client"; import { getGanttTaskUrl } from "@/lib/config/sites";
import { Task, fetchAllTasks } from "./tasks"; import { Task, fetchAllTasks } from "./tasks";
import { Project, fetchAllProjects } from "./projects"; import { Project, fetchAllProjects } from "./projects";
import { getGanttTaskUrl } from "@/lib/config/sites";
// ============================================================================
// Types
// ============================================================================
export interface ProgressMetric { export interface ProgressMetric {
id: string; id: string;
@ -45,90 +40,37 @@ export interface MissionProgress {
nextSteps: NextStep[]; nextSteps: NextStep[];
} }
// ============================================================================
// Constants
// ============================================================================
// Target goals
const TARGET_APPS = 20; const TARGET_APPS = 20;
const TARGET_REVENUE = 10000; // $10K MRR target const TARGET_REVENUE = 10000;
const TARGET_TRAVEL_FUND = 50000; // $50K travel fund const TARGET_TRAVEL_FUND = 50000;
const TARGET_RETIREMENT_TASKS = 100; // Tasks completed as proxy for progress const TARGET_RETIREMENT_TASKS = 100;
// iOS-related keywords for identifying app projects
const IOS_KEYWORDS = ["ios", "app", "swift", "mobile", "iphone", "ipad", "xcode"]; const IOS_KEYWORDS = ["ios", "app", "swift", "mobile", "iphone", "ipad", "xcode"];
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Check if a project is iOS-related
*/
function isIOSProject(project: Project): boolean { function isIOSProject(project: Project): boolean {
const nameLower = project.name.toLowerCase(); const nameLower = project.name.toLowerCase();
const descLower = (project.description || "").toLowerCase(); const descLower = (project.description || "").toLowerCase();
return IOS_KEYWORDS.some(keyword => return IOS_KEYWORDS.some((keyword) => nameLower.includes(keyword) || descLower.includes(keyword));
nameLower.includes(keyword) || descLower.includes(keyword)
);
} }
/**
* Check if a task is iOS-related
*/
function isIOSTask(task: Task): boolean { function isIOSTask(task: Task): boolean {
const titleLower = task.title.toLowerCase(); const titleLower = task.title.toLowerCase();
const descLower = (task.description || "").toLowerCase(); const descLower = (task.description || "").toLowerCase();
const hasIOSTag = task.tags.some(tag => const hasIOSTag = task.tags.some((tag) => ["ios", "app", "swift", "mobile"].includes(tag.toLowerCase()));
["ios", "app", "swift", "mobile"].includes(tag.toLowerCase()) return hasIOSTag || IOS_KEYWORDS.some((keyword) => titleLower.includes(keyword) || descLower.includes(keyword));
);
return hasIOSTag || IOS_KEYWORDS.some(keyword =>
titleLower.includes(keyword) || descLower.includes(keyword)
);
} }
/**
* Calculate percentage with bounds
*/
function calculatePercentage(current: number, target: number): number { function calculatePercentage(current: number, target: number): number {
if (target === 0) return 0; if (target === 0) return 0;
return Math.min(100, Math.max(0, Math.round((current / target) * 100))); return Math.min(100, Math.max(0, Math.round((current / target) * 100)));
} }
/** function byUpdatedDesc(a: Task, b: Task): number {
* Format currency return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
*/
function formatCurrency(amount: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(amount);
} }
// ============================================================================
// Progress Metric Functions
// ============================================================================
/**
* Get retirement progress based on completed tasks as a proxy
* In future, this could be based on actual financial data
*/
export async function getRetirementProgress(): Promise<ProgressMetric> { export async function getRetirementProgress(): Promise<ProgressMetric> {
const supabase = getServiceSupabase(); const tasks = await fetchAllTasks();
const current = tasks.filter((task) => task.status === "done").length;
// Count completed tasks as a proxy for "progress toward freedom"
const { count: completedTasks, error } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true })
.eq("status", "done");
if (error) {
console.error("Error calculating retirement progress:", error);
}
const current = completedTasks || 0;
const percentage = calculatePercentage(current, TARGET_RETIREMENT_TASKS);
return { return {
id: "retirement", id: "retirement",
@ -136,34 +78,18 @@ export async function getRetirementProgress(): Promise<ProgressMetric> {
current, current,
target: TARGET_RETIREMENT_TASKS, target: TARGET_RETIREMENT_TASKS,
unit: "tasks", unit: "tasks",
percentage, percentage: calculatePercentage(current, TARGET_RETIREMENT_TASKS),
icon: "Target", icon: "Target",
color: "from-emerald-500 to-teal-500", color: "from-emerald-500 to-teal-500",
}; };
} }
/**
* Get iOS portfolio progress
* Counts completed iOS projects and tasks
*/
export async function getiOSPortfolioProgress(): Promise<ProgressMetric> { export async function getiOSPortfolioProgress(): Promise<ProgressMetric> {
const [projects, tasks] = await Promise.all([ const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
fetchAllProjects(),
fetchAllTasks(),
]);
// Count iOS projects
const iosProjects = projects.filter(isIOSProject); const iosProjects = projects.filter(isIOSProject);
const completedIOSTasks = tasks.filter((task) => isIOSTask(task) && task.status === "done").length;
// Count completed iOS tasks as additional progress
const completedIOSTasks = tasks.filter(t =>
isIOSTask(t) && t.status === "done"
).length;
// Weight: 1 project = 1 app, 10 completed tasks = 1 app progress
const taskContribution = Math.floor(completedIOSTasks / 10); const taskContribution = Math.floor(completedIOSTasks / 10);
const current = Math.min(iosProjects.length + taskContribution, TARGET_APPS); const current = Math.min(iosProjects.length + taskContribution, TARGET_APPS);
const percentage = calculatePercentage(current, TARGET_APPS);
return { return {
id: "ios-portfolio", id: "ios-portfolio",
@ -171,42 +97,22 @@ export async function getiOSPortfolioProgress(): Promise<ProgressMetric> {
current, current,
target: TARGET_APPS, target: TARGET_APPS,
unit: "apps", unit: "apps",
percentage, percentage: calculatePercentage(current, TARGET_APPS),
icon: "Smartphone", icon: "Smartphone",
color: "from-blue-500 to-cyan-500", color: "from-blue-500 to-cyan-500",
}; };
} }
/**
* Get side hustle revenue progress
* Placeholder - will be replaced with real revenue tracking
*/
export async function getSideHustleRevenue(): Promise<ProgressMetric> { export async function getSideHustleRevenue(): Promise<ProgressMetric> {
// Placeholder: For now, calculate based on completed milestones const tasks = await fetchAllTasks();
// In future, this will pull from actual revenue data const doneTasks = tasks.filter((task) => task.status === "done");
const supabase = getServiceSupabase(); const milestones = doneTasks.filter((task) =>
task.tags.some((tag) => {
const { data: doneTasks, error } = await supabase const normalized = tag.toLowerCase();
.from("tasks") return normalized === "revenue" || normalized === "milestone";
.select("*") })
.eq("status", "done");
if (error) {
console.error("Error fetching revenue milestones:", error);
}
// Filter for tasks with revenue or milestone tags
const milestones = (doneTasks || []).filter(task => {
const tags = (task.tags || []) as string[];
return tags.some(tag =>
tag.toLowerCase() === "revenue" ||
tag.toLowerCase() === "milestone"
); );
}); const estimatedRevenue = milestones.length * 500;
// Estimate: each revenue milestone = $500 progress (placeholder logic)
const milestoneCount = milestones.length;
const estimatedRevenue = milestoneCount * 500;
return { return {
id: "revenue", id: "revenue",
@ -220,30 +126,10 @@ export async function getSideHustleRevenue(): Promise<ProgressMetric> {
}; };
} }
/**
* Get travel fund progress
* Placeholder - will be replaced with actual savings tracking
*/
export async function getTravelFundProgress(): Promise<ProgressMetric> { export async function getTravelFundProgress(): Promise<ProgressMetric> {
// Placeholder: Calculate based on "travel" tagged completed tasks const tasks = await fetchAllTasks();
const supabase = getServiceSupabase(); const doneTasks = tasks.filter((task) => task.status === "done");
const travelTasks = doneTasks.filter((task) => task.tags.some((tag) => tag.toLowerCase() === "travel"));
const { data: doneTasks, error } = await supabase
.from("tasks")
.select("*")
.eq("status", "done");
if (error) {
console.error("Error fetching travel progress:", error);
}
// Filter for tasks with travel tag
const travelTasks = (doneTasks || []).filter(task => {
const tags = (task.tags || []) as string[];
return tags.some(tag => tag.toLowerCase() === "travel");
});
// Estimate: each travel task = $500 saved (placeholder)
const estimatedSavings = travelTasks.length * 500; const estimatedSavings = travelTasks.length * 500;
return { return {
@ -258,47 +144,22 @@ export async function getTravelFundProgress(): Promise<ProgressMetric> {
}; };
} }
// ============================================================================
// Milestones Functions
// ============================================================================
/**
* Get mission milestones from completed tasks tagged with "milestone" or "mission"
*/
export async function getMissionMilestones(): Promise<Milestone[]> { export async function getMissionMilestones(): Promise<Milestone[]> {
const supabase = getServiceSupabase(); const tasks = await fetchAllTasks();
const milestoneTasks = tasks
.filter((task) => task.status === "done")
.filter((task) =>
task.tags.some((tag) => {
const normalized = tag.toLowerCase();
return normalized === "milestone" || normalized === "mission";
})
)
.sort(byUpdatedDesc);
// Fetch all completed tasks and filter for tags in code return milestoneTasks.map((task) => {
// (Supabase JSON filtering can be tricky with different setups)
const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("status", "done");
if (error) {
console.error("Error fetching mission milestones:", error);
return [];
}
// Filter for tasks with milestone or mission tags
const milestoneTasks = (data || []).filter(task => {
const tags = (task.tags || []) as string[];
return tags.some(tag =>
tag.toLowerCase() === "milestone" ||
tag.toLowerCase() === "mission"
);
});
// Sort by completion date (updated_at) descending
const sortedData = milestoneTasks.sort((a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
return sortedData.map((task, index) => {
// Determine category based on tags and title
let category: Milestone["category"] = "business"; let category: Milestone["category"] = "business";
const titleLower = task.title.toLowerCase(); const titleLower = task.title.toLowerCase();
const tagsLower = (task.tags || []).map((t: string) => t.toLowerCase()); const tagsLower = task.tags.map((tag) => tag.toLowerCase());
if (tagsLower.includes("ios") || tagsLower.includes("app") || titleLower.includes("app")) { if (tagsLower.includes("ios") || tagsLower.includes("app") || titleLower.includes("app")) {
category = "ios"; category = "ios";
@ -310,7 +171,6 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
category = "financial"; category = "financial";
} }
// Determine icon based on category
const iconMap: Record<Milestone["category"], string> = { const iconMap: Record<Milestone["category"], string> = {
ios: "Smartphone", ios: "Smartphone",
financial: "DollarSign", financial: "DollarSign",
@ -323,74 +183,39 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
id: task.id, id: task.id,
title: task.title, title: task.title,
description: task.description, description: task.description,
completedAt: task.updated_at, completedAt: task.updatedAt,
category, category,
icon: iconMap[category], icon: iconMap[category],
}; };
}); });
} }
// ============================================================================
// Next Steps Functions
// ============================================================================
/**
* Get next mission steps - high priority tasks that advance the mission
*/
export async function getNextMissionSteps(): Promise<NextStep[]> { export async function getNextMissionSteps(): Promise<NextStep[]> {
const supabase = getServiceSupabase(); const [tasks, projects] = await Promise.all([fetchAllTasks(), fetchAllProjects()]);
const projectMap = new Map(projects.map((project) => [project.id, project.name]));
// Fetch high/urgent priority tasks that are not done const prioritized = tasks
const { data: tasks, error: tasksError } = await supabase .filter((task) => (task.priority === "high" || task.priority === "urgent") && task.status !== "done")
.from("tasks") .sort((a, b) => {
.select("*") const aDue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
.in("priority", ["high", "urgent"]) const bDue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
.neq("status", "done") if (aDue !== bDue) return aDue - bDue;
.order("due_date", { ascending: true }) return byUpdatedDesc(a, b);
.limit(6); })
.slice(0, 6);
if (tasksError) { return prioritized.map((task) => ({
console.error("Error fetching next mission steps:", tasksError);
return [];
}
// Fetch projects for names
const { data: projects, error: projectsError } = await supabase
.from("projects")
.select("id, name");
if (projectsError) {
console.error("Error fetching projects:", projectsError);
}
const projectMap = new Map((projects || []).map(p => [p.id, p.name]));
return (tasks || []).map(task => ({
id: task.id, id: task.id,
title: task.title, title: task.title,
priority: task.priority as "high" | "urgent", priority: task.priority as "high" | "urgent",
projectName: projectMap.get(task.project_id), projectName: projectMap.get(task.projectId),
dueDate: task.due_date, dueDate: task.dueDate,
ganttBoardUrl: getGanttTaskUrl(task.id), ganttBoardUrl: getGanttTaskUrl(task.id),
})); }));
} }
// ============================================================================
// Combined Fetch
// ============================================================================
/**
* Fetch all mission progress data in parallel
*/
export async function fetchMissionProgress(): Promise<MissionProgress> { export async function fetchMissionProgress(): Promise<MissionProgress> {
const [ const [retirement, iosPortfolio, sideHustleRevenue, travelFund, milestones, nextSteps] = await Promise.all([
retirement,
iosPortfolio,
sideHustleRevenue,
travelFund,
milestones,
nextSteps,
] = await Promise.all([
getRetirementProgress(), getRetirementProgress(),
getiOSPortfolioProgress(), getiOSPortfolioProgress(),
getSideHustleRevenue(), getSideHustleRevenue(),

View File

@ -1,5 +1,5 @@
import { getServiceSupabase } from "@/lib/supabase/client"; import { fetchGanttApi } from "@/lib/data/gantt-api";
import { Task } from "./tasks"; import { Task, fetchAllTasks } from "./tasks";
export interface Project { export interface Project {
id: string; id: string;
@ -30,173 +30,136 @@ export interface ProjectStats {
recentTasks: Task[]; recentTasks: Task[];
} }
function toNonEmptyString(value: unknown): string | undefined { interface GanttProjectsResponse {
return typeof value === "string" && value.trim().length > 0 ? value : undefined; projects?: Array<{
id: string;
name: string;
description?: string | null;
color?: string | null;
created_at?: string;
createdAt?: string;
}>;
} }
function mapProjectRow(row: Record<string, unknown>): Project { interface GanttSprintsResponse {
sprints?: Array<{
id: string;
name: string;
status: Sprint["status"];
start_date?: string;
end_date?: string;
startDate?: string;
endDate?: string;
project_id?: string;
projectId?: string;
goal?: string | null;
}>;
}
interface GanttCurrentSprintResponse {
sprint?: {
id: string;
name: string;
status: Sprint["status"];
startDate: string;
endDate: string;
projectId: string;
goal?: string | null;
} | null;
}
function mapProject(project: NonNullable<GanttProjectsResponse["projects"]>[number]): Project {
return { return {
id: String(row.id ?? ""), id: project.id,
name: toNonEmptyString(row.name) ?? "Untitled Project", name: project.name,
description: toNonEmptyString(row.description), description: project.description ?? undefined,
color: toNonEmptyString(row.color) ?? "#3b82f6", color: project.color || "#3b82f6",
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(), createdAt: project.createdAt || project.created_at || new Date().toISOString(),
};
}
function mapSprint(sprint: NonNullable<GanttSprintsResponse["sprints"]>[number]): Sprint {
return {
id: sprint.id,
name: sprint.name,
status: sprint.status,
startDate: sprint.startDate || sprint.start_date || new Date().toISOString(),
endDate: sprint.endDate || sprint.end_date || new Date().toISOString(),
projectId: sprint.projectId || sprint.project_id || "",
goal: sprint.goal ?? undefined,
}; };
} }
/**
* Fetch all projects from Supabase
*/
export async function fetchAllProjects(): Promise<Project[]> { export async function fetchAllProjects(): Promise<Project[]> {
const supabase = getServiceSupabase(); try {
const { data, error } = await supabase const response = await fetchGanttApi<GanttProjectsResponse>("/projects");
.from("projects") return Array.isArray(response.projects) ? response.projects.map(mapProject) : [];
.select("*") } catch (error) {
.order("created_at", { ascending: true }); console.error("Error fetching projects from gantt-board API:", error);
return [];
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> { export async function countProjects(): Promise<number> {
const supabase = getServiceSupabase(); const projects = await fetchAllProjects();
const { count, error } = await supabase return projects.length;
.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[]> { export async function fetchAllSprints(): Promise<Sprint[]> {
const supabase = getServiceSupabase(); try {
const { data, error } = await supabase const response = await fetchGanttApi<GanttSprintsResponse>("/sprints");
.from("sprints") return Array.isArray(response.sprints) ? response.sprints.map(mapSprint) : [];
.select("*") } catch (error) {
.order("start_date", { ascending: false }); console.error("Error fetching sprints from gantt-board API:", error);
if (error) {
console.error("Error fetching sprints:", error);
return []; 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[]> { export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
const supabase = getServiceSupabase(); const sprints = await fetchAllSprints();
const { data, error } = await supabase return sprints.filter((sprint) => sprint.projectId === projectId);
.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> { export async function fetchActiveSprint(): Promise<Sprint | null> {
const supabase = getServiceSupabase(); try {
const { data, error } = await supabase const response = await fetchGanttApi<GanttCurrentSprintResponse>("/sprints/current");
.from("sprints") if (!response.sprint) return null;
.select("*") return {
.eq("status", "active") id: response.sprint.id,
.maybeSingle(); name: response.sprint.name,
status: response.sprint.status,
if (error) { startDate: response.sprint.startDate,
console.error("Error fetching active sprint:", error); endDate: response.sprint.endDate,
projectId: response.sprint.projectId,
goal: response.sprint.goal ?? undefined,
};
} catch (error) {
console.error("Error fetching current sprint from gantt-board API:", error);
return null; 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 }> { export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> {
const supabase = getServiceSupabase(); const sprints = await fetchAllSprints();
const counts = { planning: 0, active: 0, completed: 0, total: sprints.length };
const { data, error } = await supabase for (const sprint of sprints) {
.from("sprints") if (sprint.status === "planning") counts.planning++;
.select("status"); else if (sprint.status === "active") counts.active++;
else if (sprint.status === "completed") counts.completed++;
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; return counts;
} }
/**
* Calculate project statistics
*/
export function calculateProjectStats(project: Project, tasks: Task[]): ProjectStats { export function calculateProjectStats(project: Project, tasks: Task[]): ProjectStats {
const projectTasks = tasks.filter(t => t.projectId === project.id); const projectTasks = tasks.filter((task) => task.projectId === project.id);
const completedTasks = projectTasks.filter(t => t.status === "done"); const completedTasks = projectTasks.filter((task) => task.status === "done");
const inProgressTasks = projectTasks.filter(t => t.status === "in-progress"); const inProgressTasks = projectTasks.filter((task) => task.status === "in-progress");
const urgentTasks = projectTasks.filter(t => t.priority === "urgent" && t.status !== "done"); const urgentTasks = projectTasks.filter((task) => task.priority === "urgent" && task.status !== "done");
const highPriorityTasks = projectTasks.filter(t => t.priority === "high" && t.status !== "done"); const highPriorityTasks = projectTasks.filter((task) => task.priority === "high" && task.status !== "done");
const totalTasks = projectTasks.length; const totalTasks = projectTasks.length;
const progress = totalTasks > 0 ? Math.round((completedTasks.length / totalTasks) * 100) : 0; const progress = totalTasks > 0 ? Math.round((completedTasks.length / totalTasks) * 100) : 0;
// Get recent tasks (last 5 updated)
const recentTasks = [...projectTasks] const recentTasks = [...projectTasks]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 5); .slice(0, 5);
@ -213,54 +176,11 @@ export function calculateProjectStats(project: Project, tasks: Task[]): ProjectS
}; };
} }
/**
* Fetch all projects with their statistics
*/
export async function fetchProjectsWithStats(): Promise<ProjectStats[]> { export async function fetchProjectsWithStats(): Promise<ProjectStats[]> {
const supabase = getServiceSupabase(); const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
return projects.map((project) => calculateProjectStats(project, tasks));
// 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): { export function getProjectHealth(stats: ProjectStats): {
status: "healthy" | "warning" | "critical"; status: "healthy" | "warning" | "critical";
label: string; label: string;
@ -268,23 +188,17 @@ export function getProjectHealth(stats: ProjectStats): {
} { } {
const { progress, urgentTasks, highPriorityTasks, totalTasks } = stats; const { progress, urgentTasks, highPriorityTasks, totalTasks } = stats;
// Critical: Has urgent tasks or no progress with many open tasks
if (urgentTasks > 0 || (totalTasks > 5 && progress === 0)) { if (urgentTasks > 0 || (totalTasks > 5 && progress === 0)) {
return { status: "critical", label: "Needs Attention", color: "text-red-500" }; 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)) { if (highPriorityTasks > 2 || (totalTasks > 10 && progress < 30)) {
return { status: "warning", label: "At Risk", color: "text-yellow-500" }; 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" }; return { status: "healthy", label: "On Track", color: "text-green-500" };
} }
/**
* Calculate days remaining in sprint
*/
export function getSprintDaysRemaining(sprint: Sprint): number { export function getSprintDaysRemaining(sprint: Sprint): number {
const endDate = new Date(sprint.endDate); const endDate = new Date(sprint.endDate);
const today = new Date(); const today = new Date();
@ -292,11 +206,11 @@ export function getSprintDaysRemaining(sprint: Sprint): number {
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
} }
/**
* Format sprint date range
*/
export function formatSprintDateRange(sprint: Sprint): string { export function formatSprintDateRange(sprint: Sprint): string {
const start = new Date(sprint.startDate); const start = new Date(sprint.startDate);
const end = new Date(sprint.endDate); const end = new Date(sprint.endDate);
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`; return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}`;
} }

View File

@ -1,5 +1,5 @@
import { fetchAllProjects, countProjects } from "./projects"; import { fetchAllProjects, countProjects } from "./projects";
import { countActiveTasks, countTotalTasks } from "./tasks"; import { countActiveTasks, countTotalTasks, fetchAllTasks } from "./tasks";
export interface DashboardStats { export interface DashboardStats {
activeTasksCount: number; activeTasksCount: number;
@ -32,24 +32,13 @@ function calculateYearProgress(): { progress: number; day: number; totalDays: nu
* For now, this uses a simple heuristic based on done tasks vs total tasks * For now, this uses a simple heuristic based on done tasks vs total tasks
*/ */
async function calculateGoalsProgress(): Promise<number> { async function calculateGoalsProgress(): Promise<number> {
const supabase = (await import("@/lib/supabase/client")).getServiceSupabase(); const tasks = await fetchAllTasks();
if (tasks.length === 0) {
// Get done tasks count
const { count: doneCount, error: doneError } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true })
.eq("status", "done");
// Get total tasks count
const { count: totalCount, error: totalError } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true });
if (doneError || totalError || !totalCount || totalCount === 0) {
return 0; return 0;
} }
return Math.round(((doneCount || 0) / totalCount) * 100); const doneCount = tasks.filter((task) => task.status === "done").length;
return Math.round((doneCount / tasks.length) * 100);
} }
/** /**

View File

@ -1,4 +1,4 @@
import { getServiceSupabase } from "@/lib/supabase/client"; import { fetchGanttApi } from "@/lib/data/gantt-api";
export interface Task { export interface Task {
id: string; id: string;
@ -27,121 +27,10 @@ export interface Task {
attachments: unknown[]; attachments: unknown[];
} }
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"]; interface GanttTasksResponse {
tasks?: Task[];
function isTaskStatus(value: unknown): value is Task["status"] {
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
} }
function toNonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}
function mapTaskRow(row: Record<string, unknown>): Task {
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: isTaskStatus(row.status) ? row.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),
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url),
updatedById: toNonEmptyString(row.updated_by_id),
updatedByName: toNonEmptyString(row.updated_by_name),
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url),
assigneeId: toNonEmptyString(row.assignee_id),
assigneeName: toNonEmptyString(row.assignee_name),
assigneeEmail: toNonEmptyString(row.assignee_email),
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url),
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 : [],
};
}
/**
* Fetch all tasks from Supabase
*/
export async function fetchAllTasks(): Promise<Task[]> {
const supabase = getServiceSupabase();
const { data, error } = await supabase
.from("tasks")
.select("*")
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching tasks:", error);
throw new Error(`Failed to fetch tasks: ${error.message}`);
}
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
}
/**
* Fetch active tasks (status != 'done')
*/
export async function fetchActiveTasks(): Promise<Task[]> {
const supabase = getServiceSupabase();
const { data, error } = await supabase
.from("tasks")
.select("*")
.neq("status", "done")
.order("created_at", { ascending: true });
if (error) {
console.error("Error fetching active tasks:", error);
throw new Error(`Failed to fetch active tasks: ${error.message}`);
}
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
}
/**
* Count active tasks
*/
export async function countActiveTasks(): Promise<number> {
const supabase = getServiceSupabase();
const { count, error } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true })
.neq("status", "done");
if (error) {
console.error("Error counting active tasks:", error);
return 0;
}
return count || 0;
}
/**
* Count total tasks
*/
export async function countTotalTasks(): Promise<number> {
const supabase = getServiceSupabase();
const { count, error } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true });
if (error) {
console.error("Error counting total tasks:", error);
return 0;
}
return count || 0;
}
/**
* Get task counts by status
*/
export interface TaskStatusCounts { export interface TaskStatusCounts {
open: number; open: number;
inProgress: number; inProgress: number;
@ -150,135 +39,94 @@ export interface TaskStatusCounts {
total: number; total: number;
} }
function isDoneStatus(status: Task["status"]): boolean {
return status === "done";
}
function isOpenStatus(status: Task["status"]): boolean {
return status === "open" || status === "todo" || status === "blocked";
}
function isReviewStatus(status: Task["status"]): boolean {
return status === "review" || status === "validate";
}
function byUpdatedDesc(a: Task, b: Task): number {
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
function toDateOnly(value: string): string {
return value.split("T")[0];
}
async function fetchTasksFromApi(): Promise<Task[]> {
try {
const response = await fetchGanttApi<GanttTasksResponse>("/tasks?scope=all&include=detail");
return Array.isArray(response.tasks) ? response.tasks : [];
} catch (error) {
console.error("Error fetching tasks from gantt-board API:", error);
return [];
}
}
export async function fetchAllTasks(): Promise<Task[]> {
return fetchTasksFromApi();
}
export async function fetchActiveTasks(): Promise<Task[]> {
const tasks = await fetchTasksFromApi();
return tasks.filter((task) => !isDoneStatus(task.status));
}
export async function countActiveTasks(): Promise<number> {
const tasks = await fetchTasksFromApi();
return tasks.filter((task) => !isDoneStatus(task.status)).length;
}
export async function countTotalTasks(): Promise<number> {
const tasks = await fetchTasksFromApi();
return tasks.length;
}
export async function getTaskStatusCounts(): Promise<TaskStatusCounts> { export async function getTaskStatusCounts(): Promise<TaskStatusCounts> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const counts: TaskStatusCounts = { open: 0, inProgress: 0, review: 0, done: 0, total: tasks.length };
// Get all tasks and count by status for (const task of tasks) {
const { data, error } = await supabase if (isOpenStatus(task.status)) counts.open++;
.from("tasks") else if (task.status === "in-progress") counts.inProgress++;
.select("status"); else if (isReviewStatus(task.status)) counts.review++;
else if (task.status === "done") counts.done++;
if (error) {
console.error("Error fetching task status counts:", error);
return { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
}
const counts = { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
for (const row of data || []) {
counts.total++;
const status = row.status;
if (status === "open" || status === "todo" || status === "blocked") {
counts.open++;
} else if (status === "in-progress") {
counts.inProgress++;
} else if (status === "review" || status === "validate") {
counts.review++;
} else if (status === "done") {
counts.done++;
}
} }
return counts; return counts;
} }
/**
* Count high priority tasks (high or urgent priority, not done)
*/
export async function countHighPriorityTasks(): Promise<number> { export async function countHighPriorityTasks(): Promise<number> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const { count, error } = await supabase return tasks.filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status)).length;
.from("tasks")
.select("*", { count: "exact", head: true })
.in("priority", ["high", "urgent"])
.neq("status", "done");
if (error) {
console.error("Error counting high priority tasks:", error);
return 0;
}
return count || 0;
} }
/**
* Count overdue tasks (due date in the past, not done)
*/
export async function countOverdueTasks(): Promise<number> { export async function countOverdueTasks(): Promise<number> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const today = new Date().toISOString().split("T")[0]; const today = toDateOnly(new Date().toISOString());
return tasks.filter((task) => Boolean(task.dueDate) && !isDoneStatus(task.status) && toDateOnly(task.dueDate as string) < today).length;
const { count, error } = await supabase
.from("tasks")
.select("*", { count: "exact", head: true })
.lt("due_date", today)
.neq("status", "done")
.not("due_date", "is", null);
if (error) {
console.error("Error counting overdue tasks:", error);
return 0;
}
return count || 0;
} }
/**
* Fetch recently updated tasks (last 5)
*/
export async function fetchRecentlyUpdatedTasks(limit = 5): Promise<Task[]> { export async function fetchRecentlyUpdatedTasks(limit = 5): Promise<Task[]> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const { data, error } = await supabase return [...tasks].sort(byUpdatedDesc).slice(0, limit);
.from("tasks")
.select("*")
.order("updated_at", { ascending: false })
.limit(limit);
if (error) {
console.error("Error fetching recently updated tasks:", error);
return [];
}
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
} }
/**
* Fetch recently completed tasks (last 5)
*/
export async function fetchRecentlyCompletedTasks(limit = 5): Promise<Task[]> { export async function fetchRecentlyCompletedTasks(limit = 5): Promise<Task[]> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const { data, error } = await supabase return tasks.filter((task) => isDoneStatus(task.status)).sort(byUpdatedDesc).slice(0, limit);
.from("tasks")
.select("*")
.eq("status", "done")
.order("updated_at", { ascending: false })
.limit(limit);
if (error) {
console.error("Error fetching recently completed tasks:", error);
return [];
}
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
} }
/**
* Fetch high priority open tasks (top 5)
*/
export async function fetchHighPriorityOpenTasks(limit = 5): Promise<Task[]> { export async function fetchHighPriorityOpenTasks(limit = 5): Promise<Task[]> {
const supabase = getServiceSupabase(); const tasks = await fetchTasksFromApi();
const { data, error } = await supabase return tasks
.from("tasks") .filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status))
.select("*") .sort(byUpdatedDesc)
.in("priority", ["high", "urgent"]) .slice(0, limit);
.neq("status", "done")
.order("updated_at", { ascending: false })
.limit(limit);
if (error) {
console.error("Error fetching high priority open tasks:", error);
return [];
}
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
} }

View File

@ -6,7 +6,8 @@
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint",
"test:cli-contract": "bash scripts/tests/reuse-gantt-cli-contract.sh"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

30
scripts/lib/gantt_cli.sh Executable file
View File

@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MISSION_CONTROL_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
GANTT_BOARD_DIR="${GANTT_BOARD_DIR:-$MISSION_CONTROL_ROOT/../gantt-board}"
resolve_cli_script() {
local script_name="$1"
local script_path="$GANTT_BOARD_DIR/scripts/$script_name"
if [[ ! -x "$script_path" ]]; then
echo "Missing executable: $script_path" >&2
echo "Set GANTT_BOARD_DIR to your gantt-board project root." >&2
exit 1
fi
echo "$script_path"
}
run_gantt_cli() {
local script_name="$1"
shift || true
local script_path
script_path="$(resolve_cli_script "$script_name")"
"$script_path" "$@"
}

9
scripts/project.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib/gantt_cli.sh
source "$SCRIPT_DIR/lib/gantt_cli.sh"
run_gantt_cli "project.sh" "$@"

9
scripts/task.sh Executable file
View File

@ -0,0 +1,9 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./lib/gantt_cli.sh
source "$SCRIPT_DIR/lib/gantt_cli.sh"
run_gantt_cli "task.sh" "$@"

View File

@ -0,0 +1,66 @@
#!/bin/bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TMP_DIR="$(mktemp -d)"
MOCK_GANTT_DIR="$TMP_DIR/gantt-board"
MOCK_LOG="$TMP_DIR/delegation.log"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
mkdir -p "$MOCK_GANTT_DIR/scripts"
touch "$MOCK_LOG"
cat > "$MOCK_GANTT_DIR/scripts/task.sh" <<'MOCK_TASK'
#!/bin/bash
set -euo pipefail
echo "task.sh $*" >> "${MOCK_DELEGATION_LOG:?}"
MOCK_TASK
cat > "$MOCK_GANTT_DIR/scripts/project.sh" <<'MOCK_PROJECT'
#!/bin/bash
set -euo pipefail
echo "project.sh $*" >> "${MOCK_DELEGATION_LOG:?}"
MOCK_PROJECT
chmod +x "$MOCK_GANTT_DIR/scripts/task.sh" "$MOCK_GANTT_DIR/scripts/project.sh"
export MOCK_DELEGATION_LOG="$MOCK_LOG"
export GANTT_BOARD_DIR="$MOCK_GANTT_DIR"
bash "$ROOT_DIR/scripts/task.sh" list --json
bash "$ROOT_DIR/scripts/project.sh" create --name "Demo"
TASK_ID="65bc8c5a-212a-4532-a027-ea424e62900e" \
TASK_STATUS="review" \
node "$ROOT_DIR/scripts/update-task-status.js"
if ! grep -F "task.sh list --json" "$MOCK_LOG" >/dev/null 2>&1; then
echo "Expected task wrapper to delegate to gantt-board task.sh" >&2
cat "$MOCK_LOG" >&2
exit 1
fi
if ! grep -F "project.sh create --name Demo" "$MOCK_LOG" >/dev/null 2>&1; then
echo "Expected project wrapper to delegate to gantt-board project.sh" >&2
cat "$MOCK_LOG" >&2
exit 1
fi
if ! grep -F "task.sh update 65bc8c5a-212a-4532-a027-ea424e62900e --status review" "$MOCK_LOG" >/dev/null 2>&1; then
echo "Expected update-task-status.js to delegate to gantt-board task.sh update" >&2
cat "$MOCK_LOG" >&2
exit 1
fi
# No direct database credentials/queries in scripts
if rg -n --glob '!scripts/tests/*' "SUPABASE_SERVICE_ROLE_KEY|createClient\\(|rest/v1|from\\('tasks'\\)|from\\(\"tasks\"\\)" "$ROOT_DIR/scripts" >/dev/null 2>&1; then
echo "Direct DB references found under scripts/. CLI must remain API passthrough only." >&2
rg -n --glob '!scripts/tests/*' "SUPABASE_SERVICE_ROLE_KEY|createClient\\(|rest/v1|from\\('tasks'\\)|from\\(\"tasks\"\\)" "$ROOT_DIR/scripts" >&2 || true
exit 1
fi
echo "reuse-gantt-cli-contract: OK"

View File

@ -1,28 +1,28 @@
const { createClient } = require('@supabase/supabase-js'); #!/usr/bin/env node
const supabaseUrl = 'https://qnatchrjlpehiijwtreh.supabase.co'; const { spawnSync } = require("node:child_process");
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuYXRjaHJqbHBlaGlpand0cmVoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE2NDA0MzYsImV4cCI6MjA4NzIxNjQzNn0.47XOMrQBzcQEh71phQflPoO4v79Jk3rft7BC72KHDvA'; const path = require("node:path");
const supabase = createClient(supabaseUrl, supabaseKey); const taskId = process.env.TASK_ID || process.argv[2];
const status = process.env.TASK_STATUS || process.argv[3] || "review";
const ganttBoardDir =
process.env.GANTT_BOARD_DIR ||
path.resolve(__dirname, "..", "..", "..", "gantt-board");
const taskCliPath = path.join(ganttBoardDir, "scripts", "task.sh");
async function updateTaskStatus() { if (!taskId) {
const taskId = '65bc8c5a-212a-4532-a027-ea424e62900e'; console.error("Usage: TASK_ID=<uuid> node scripts/update-task-status.js [taskId] [status]");
const { data, error } = await supabase
.from('tasks')
.update({
status: 'review',
updated_at: new Date().toISOString()
})
.eq('id', taskId)
.select();
if (error) {
console.error('Error updating task:', error);
process.exit(1); process.exit(1);
}
console.log('Task updated successfully:', data);
} }
updateTaskStatus(); const result = spawnSync(taskCliPath, ["update", taskId, "--status", status], {
stdio: "inherit",
env: process.env,
});
if (result.error) {
console.error("Failed to execute gantt-board task CLI:", result.error.message);
process.exit(1);
}
process.exit(result.status ?? 1);