diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f3e9dae
--- /dev/null
+++ b/.env.example
@@ -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
diff --git a/.gitignore b/.gitignore
index b831ca9..0948a60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,6 +33,8 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
+!.env.local.example
# vercel
.vercel
diff --git a/README.md b/README.md
index 5a68555..187c17a 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,7 @@ mission-control/
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
+GANTT_API_BASE_URL= # ex: http://gantt-board:3000/api (Docker network)
# Optional
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
@@ -150,6 +151,9 @@ NEXT_PUBLIC_SUPABASE_SITE_URL=
NEXT_PUBLIC_GOOGLE_URL=
NEXT_PUBLIC_GOOGLE_CALENDAR_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.
@@ -170,6 +174,30 @@ npm run build
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
The app deploys automatically to Vercel on push to main:
diff --git a/app/podcast/page.tsx b/app/podcast/page.tsx
new file mode 100644
index 0000000..21172c6
--- /dev/null
+++ b/app/podcast/page.tsx
@@ -0,0 +1,42 @@
+export default function PodcastPage() {
+ return (
+
+
+
OpenClaw Daily Digest Podcast
+
+ Daily tech news and insights for developers, delivered as a podcast.
+
+
+
+
+
+
About
+
+ • Daily episodes (~5 minutes)
+ • iOS, AI, coding tools, and entrepreneurship news
+ • Generated using AI text-to-speech
+ • Available every morning at 7 AM CST
+
+
+
+
+
New episodes are generated automatically from the daily digest.
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/podcast/rss.xml/route.ts b/app/podcast/rss.xml/route.ts
new file mode 100644
index 0000000..2647143
--- /dev/null
+++ b/app/podcast/rss.xml/route.ts
@@ -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 = `
+
+
+ OpenClaw Daily Digest
+ https://mission-control-rho-pink.vercel.app/podcast
+ en-us
+ © 2026 OpenClaw
+ OpenClaw
+ Daily tech news and insights for developers
+
+
+ no
+ -
+
Welcome to OpenClaw Daily Digest Podcast
+ Your daily tech news podcast is coming soon. Check back for the first episode!
+ ${new Date().toUTCString()}
+ welcome
+
+
+ `;
+
+ 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 }
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/data/gantt-api.ts b/lib/data/gantt-api.ts
new file mode 100644
index 0000000..3033a2b
--- /dev/null
+++ b/lib/data/gantt-api.ts
@@ -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 = {};
+
+ 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(endpoint: string): Promise {
+ 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;
+}
diff --git a/lib/data/mission.ts b/lib/data/mission.ts
index 25a8d3c..e6e0352 100644
--- a/lib/data/mission.ts
+++ b/lib/data/mission.ts
@@ -1,11 +1,6 @@
-import { getServiceSupabase } from "@/lib/supabase/client";
+import { getGanttTaskUrl } from "@/lib/config/sites";
import { Task, fetchAllTasks } from "./tasks";
import { Project, fetchAllProjects } from "./projects";
-import { getGanttTaskUrl } from "@/lib/config/sites";
-
-// ============================================================================
-// Types
-// ============================================================================
export interface ProgressMetric {
id: string;
@@ -45,90 +40,37 @@ export interface MissionProgress {
nextSteps: NextStep[];
}
-// ============================================================================
-// Constants
-// ============================================================================
-
-// Target goals
const TARGET_APPS = 20;
-const TARGET_REVENUE = 10000; // $10K MRR target
-const TARGET_TRAVEL_FUND = 50000; // $50K travel fund
-const TARGET_RETIREMENT_TASKS = 100; // Tasks completed as proxy for progress
-
-// iOS-related keywords for identifying app projects
+const TARGET_REVENUE = 10000;
+const TARGET_TRAVEL_FUND = 50000;
+const TARGET_RETIREMENT_TASKS = 100;
const IOS_KEYWORDS = ["ios", "app", "swift", "mobile", "iphone", "ipad", "xcode"];
-// ============================================================================
-// Helper Functions
-// ============================================================================
-
-/**
- * Check if a project is iOS-related
- */
function isIOSProject(project: Project): boolean {
const nameLower = project.name.toLowerCase();
const descLower = (project.description || "").toLowerCase();
- return IOS_KEYWORDS.some(keyword =>
- nameLower.includes(keyword) || descLower.includes(keyword)
- );
+ return IOS_KEYWORDS.some((keyword) => nameLower.includes(keyword) || descLower.includes(keyword));
}
-/**
- * Check if a task is iOS-related
- */
function isIOSTask(task: Task): boolean {
const titleLower = task.title.toLowerCase();
const descLower = (task.description || "").toLowerCase();
- const hasIOSTag = task.tags.some(tag =>
- ["ios", "app", "swift", "mobile"].includes(tag.toLowerCase())
- );
- return hasIOSTag || IOS_KEYWORDS.some(keyword =>
- titleLower.includes(keyword) || descLower.includes(keyword)
- );
+ const hasIOSTag = task.tags.some((tag) => ["ios", "app", "swift", "mobile"].includes(tag.toLowerCase()));
+ return hasIOSTag || IOS_KEYWORDS.some((keyword) => titleLower.includes(keyword) || descLower.includes(keyword));
}
-/**
- * Calculate percentage with bounds
- */
function calculatePercentage(current: number, target: number): number {
if (target === 0) return 0;
return Math.min(100, Math.max(0, Math.round((current / target) * 100)));
}
-/**
- * Format currency
- */
-function formatCurrency(amount: number): string {
- return new Intl.NumberFormat("en-US", {
- style: "currency",
- currency: "USD",
- maximumFractionDigits: 0,
- }).format(amount);
+function byUpdatedDesc(a: Task, b: Task): number {
+ return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
}
-// ============================================================================
-// 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 {
- const supabase = getServiceSupabase();
-
- // 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);
+ const tasks = await fetchAllTasks();
+ const current = tasks.filter((task) => task.status === "done").length;
return {
id: "retirement",
@@ -136,34 +78,18 @@ export async function getRetirementProgress(): Promise {
current,
target: TARGET_RETIREMENT_TASKS,
unit: "tasks",
- percentage,
+ percentage: calculatePercentage(current, TARGET_RETIREMENT_TASKS),
icon: "Target",
color: "from-emerald-500 to-teal-500",
};
}
-/**
- * Get iOS portfolio progress
- * Counts completed iOS projects and tasks
- */
export async function getiOSPortfolioProgress(): Promise {
- const [projects, tasks] = await Promise.all([
- fetchAllProjects(),
- fetchAllTasks(),
- ]);
-
- // Count iOS projects
+ const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
const iosProjects = projects.filter(isIOSProject);
-
- // 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 completedIOSTasks = tasks.filter((task) => isIOSTask(task) && task.status === "done").length;
const taskContribution = Math.floor(completedIOSTasks / 10);
const current = Math.min(iosProjects.length + taskContribution, TARGET_APPS);
- const percentage = calculatePercentage(current, TARGET_APPS);
return {
id: "ios-portfolio",
@@ -171,42 +97,22 @@ export async function getiOSPortfolioProgress(): Promise {
current,
target: TARGET_APPS,
unit: "apps",
- percentage,
+ percentage: calculatePercentage(current, TARGET_APPS),
icon: "Smartphone",
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 {
- // Placeholder: For now, calculate based on completed milestones
- // In future, this will pull from actual revenue data
- const supabase = getServiceSupabase();
-
- const { data: doneTasks, error } = await supabase
- .from("tasks")
- .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"
- );
- });
-
- // Estimate: each revenue milestone = $500 progress (placeholder logic)
- const milestoneCount = milestones.length;
- const estimatedRevenue = milestoneCount * 500;
+ const tasks = await fetchAllTasks();
+ const doneTasks = tasks.filter((task) => task.status === "done");
+ const milestones = doneTasks.filter((task) =>
+ task.tags.some((tag) => {
+ const normalized = tag.toLowerCase();
+ return normalized === "revenue" || normalized === "milestone";
+ })
+ );
+ const estimatedRevenue = milestones.length * 500;
return {
id: "revenue",
@@ -220,30 +126,10 @@ export async function getSideHustleRevenue(): Promise {
};
}
-/**
- * Get travel fund progress
- * Placeholder - will be replaced with actual savings tracking
- */
export async function getTravelFundProgress(): Promise {
- // Placeholder: Calculate based on "travel" tagged completed tasks
- const supabase = getServiceSupabase();
-
- 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 tasks = await fetchAllTasks();
+ const doneTasks = tasks.filter((task) => task.status === "done");
+ const travelTasks = doneTasks.filter((task) => task.tags.some((tag) => tag.toLowerCase() === "travel"));
const estimatedSavings = travelTasks.length * 500;
return {
@@ -258,48 +144,23 @@ export async function getTravelFundProgress(): Promise {
};
}
-// ============================================================================
-// Milestones Functions
-// ============================================================================
-
-/**
- * Get mission milestones from completed tasks tagged with "milestone" or "mission"
- */
export async function getMissionMilestones(): Promise {
- const supabase = getServiceSupabase();
-
- // Fetch all completed tasks and filter for tags in code
- // (Supabase JSON filtering can be tricky with different setups)
- const { data, error } = await supabase
- .from("tasks")
- .select("*")
- .eq("status", "done");
+ 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);
- 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
+ return milestoneTasks.map((task) => {
let category: Milestone["category"] = "business";
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")) {
category = "ios";
} else if (tagsLower.includes("travel") || titleLower.includes("travel")) {
@@ -310,7 +171,6 @@ export async function getMissionMilestones(): Promise {
category = "financial";
}
- // Determine icon based on category
const iconMap: Record = {
ios: "Smartphone",
financial: "DollarSign",
@@ -323,74 +183,39 @@ export async function getMissionMilestones(): Promise {
id: task.id,
title: task.title,
description: task.description,
- completedAt: task.updated_at,
+ completedAt: task.updatedAt,
category,
icon: iconMap[category],
};
});
}
-// ============================================================================
-// Next Steps Functions
-// ============================================================================
-
-/**
- * Get next mission steps - high priority tasks that advance the mission
- */
export async function getNextMissionSteps(): Promise {
- const supabase = getServiceSupabase();
-
- // Fetch high/urgent priority tasks that are not done
- const { data: tasks, error: tasksError } = await supabase
- .from("tasks")
- .select("*")
- .in("priority", ["high", "urgent"])
- .neq("status", "done")
- .order("due_date", { ascending: true })
- .limit(6);
+ const [tasks, projects] = await Promise.all([fetchAllTasks(), fetchAllProjects()]);
+ const projectMap = new Map(projects.map((project) => [project.id, project.name]));
- if (tasksError) {
- console.error("Error fetching next mission steps:", tasksError);
- return [];
- }
+ const prioritized = tasks
+ .filter((task) => (task.priority === "high" || task.priority === "urgent") && task.status !== "done")
+ .sort((a, b) => {
+ const aDue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
+ const bDue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
+ if (aDue !== bDue) return aDue - bDue;
+ return byUpdatedDesc(a, b);
+ })
+ .slice(0, 6);
- // 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 => ({
+ return prioritized.map((task) => ({
id: task.id,
title: task.title,
priority: task.priority as "high" | "urgent",
- projectName: projectMap.get(task.project_id),
- dueDate: task.due_date,
+ projectName: projectMap.get(task.projectId),
+ dueDate: task.dueDate,
ganttBoardUrl: getGanttTaskUrl(task.id),
}));
}
-// ============================================================================
-// Combined Fetch
-// ============================================================================
-
-/**
- * Fetch all mission progress data in parallel
- */
export async function fetchMissionProgress(): Promise {
- const [
- retirement,
- iosPortfolio,
- sideHustleRevenue,
- travelFund,
- milestones,
- nextSteps,
- ] = await Promise.all([
+ const [retirement, iosPortfolio, sideHustleRevenue, travelFund, milestones, nextSteps] = await Promise.all([
getRetirementProgress(),
getiOSPortfolioProgress(),
getSideHustleRevenue(),
diff --git a/lib/data/projects.ts b/lib/data/projects.ts
index dc8e43c..fe020db 100644
--- a/lib/data/projects.ts
+++ b/lib/data/projects.ts
@@ -1,5 +1,5 @@
-import { getServiceSupabase } from "@/lib/supabase/client";
-import { Task } from "./tasks";
+import { fetchGanttApi } from "@/lib/data/gantt-api";
+import { Task, fetchAllTasks } from "./tasks";
export interface Project {
id: string;
@@ -30,173 +30,136 @@ export interface ProjectStats {
recentTasks: Task[];
}
-function toNonEmptyString(value: unknown): string | undefined {
- return typeof value === "string" && value.trim().length > 0 ? value : undefined;
+interface GanttProjectsResponse {
+ projects?: Array<{
+ id: string;
+ name: string;
+ description?: string | null;
+ color?: string | null;
+ created_at?: string;
+ createdAt?: string;
+ }>;
}
-function mapProjectRow(row: Record): 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[number]): 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(),
+ id: project.id,
+ name: project.name,
+ description: project.description ?? undefined,
+ color: project.color || "#3b82f6",
+ createdAt: project.createdAt || project.created_at || new Date().toISOString(),
+ };
+}
+
+function mapSprint(sprint: NonNullable[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 {
- 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}`);
+ try {
+ const response = await fetchGanttApi("/projects");
+ return Array.isArray(response.projects) ? response.projects.map(mapProject) : [];
+ } catch (error) {
+ console.error("Error fetching projects from gantt-board API:", error);
+ return [];
}
-
- return (data || []).map((row) => mapProjectRow(row as Record));
}
-/**
- * Count total projects
- */
export async function countProjects(): Promise {
- 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;
+ const projects = await fetchAllProjects();
+ return projects.length;
}
-// 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): 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 {
- const supabase = getServiceSupabase();
- const { data, error } = await supabase
- .from("sprints")
- .select("*")
- .order("start_date", { ascending: false });
-
- if (error) {
- console.error("Error fetching sprints:", error);
+ try {
+ const response = await fetchGanttApi("/sprints");
+ return Array.isArray(response.sprints) ? response.sprints.map(mapSprint) : [];
+ } catch (error) {
+ console.error("Error fetching sprints from gantt-board API:", error);
return [];
}
-
- return (data || []).map((row) => mapSprintRow(row as Record));
}
-/**
- * Fetch sprints for a specific project
- */
export async function fetchProjectSprints(projectId: string): Promise {
- 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));
+ const sprints = await fetchAllSprints();
+ return sprints.filter((sprint) => sprint.projectId === projectId);
}
-/**
- * Fetch active sprint
- */
export async function fetchActiveSprint(): Promise {
- const supabase = getServiceSupabase();
- const { data, error } = await supabase
- .from("sprints")
- .select("*")
- .eq("status", "active")
- .maybeSingle();
-
- if (error) {
- console.error("Error fetching active sprint:", error);
+ try {
+ const response = await fetchGanttApi("/sprints/current");
+ if (!response.sprint) return null;
+ return {
+ id: response.sprint.id,
+ name: response.sprint.name,
+ status: response.sprint.status,
+ startDate: response.sprint.startDate,
+ 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 data ? mapSprintRow(data as Record) : 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 sprints = await fetchAllSprints();
+ const counts = { planning: 0, active: 0, completed: 0, total: sprints.length };
+ for (const sprint of sprints) {
+ if (sprint.status === "planning") counts.planning++;
+ else if (sprint.status === "active") counts.active++;
+ else if (sprint.status === "completed") counts.completed++;
}
-
- 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 projectTasks = tasks.filter((task) => task.projectId === project.id);
+ const completedTasks = projectTasks.filter((task) => task.status === "done");
+ const inProgressTasks = projectTasks.filter((task) => task.status === "in-progress");
+ const urgentTasks = projectTasks.filter((task) => task.priority === "urgent" && task.status !== "done");
+ const highPriorityTasks = projectTasks.filter((task) => task.priority === "high" && task.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);
@@ -213,78 +176,29 @@ export function calculateProjectStats(project: Project, tasks: Task[]): ProjectS
};
}
-/**
- * Fetch all projects with their statistics
- */
export async function fetchProjectsWithStats(): Promise {
- 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));
- 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));
+ const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
+ 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();
@@ -292,11 +206,11 @@ export function getSprintDaysRemaining(sprint: Sprint): number {
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" })}`;
+ return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ })}`;
}
diff --git a/lib/data/stats.ts b/lib/data/stats.ts
index 7b7af5b..5b1edd6 100644
--- a/lib/data/stats.ts
+++ b/lib/data/stats.ts
@@ -1,5 +1,5 @@
import { fetchAllProjects, countProjects } from "./projects";
-import { countActiveTasks, countTotalTasks } from "./tasks";
+import { countActiveTasks, countTotalTasks, fetchAllTasks } from "./tasks";
export interface DashboardStats {
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
*/
async function calculateGoalsProgress(): Promise {
- const supabase = (await import("@/lib/supabase/client")).getServiceSupabase();
-
- // 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) {
+ const tasks = await fetchAllTasks();
+ if (tasks.length === 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);
}
/**
diff --git a/lib/data/tasks.ts b/lib/data/tasks.ts
index f28885f..7a68336 100644
--- a/lib/data/tasks.ts
+++ b/lib/data/tasks.ts
@@ -1,4 +1,4 @@
-import { getServiceSupabase } from "@/lib/supabase/client";
+import { fetchGanttApi } from "@/lib/data/gantt-api";
export interface Task {
id: string;
@@ -27,121 +27,10 @@ export interface Task {
attachments: unknown[];
}
-const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
-
-function isTaskStatus(value: unknown): value is Task["status"] {
- return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
+interface GanttTasksResponse {
+ tasks?: Task[];
}
-function toNonEmptyString(value: unknown): string | undefined {
- return typeof value === "string" && value.trim().length > 0 ? value : undefined;
-}
-
-function mapTaskRow(row: Record): 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 {
- 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));
-}
-
-/**
- * Fetch active tasks (status != 'done')
- */
-export async function fetchActiveTasks(): Promise {
- 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));
-}
-
-/**
- * Count active tasks
- */
-export async function countActiveTasks(): Promise {
- 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 {
- 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 {
open: number;
inProgress: number;
@@ -150,135 +39,94 @@ export interface TaskStatusCounts {
total: number;
}
-export async function getTaskStatusCounts(): Promise {
- const supabase = getServiceSupabase();
-
- // Get all tasks and count by status
- const { data, error } = await supabase
- .from("tasks")
- .select("status");
+function isDoneStatus(status: Task["status"]): boolean {
+ return status === "done";
+}
- if (error) {
- console.error("Error fetching task status counts:", error);
- return { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
+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 {
+ try {
+ const response = await fetchGanttApi("/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 [];
}
+}
- 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++;
- }
+export async function fetchAllTasks(): Promise {
+ return fetchTasksFromApi();
+}
+
+export async function fetchActiveTasks(): Promise {
+ const tasks = await fetchTasksFromApi();
+ return tasks.filter((task) => !isDoneStatus(task.status));
+}
+
+export async function countActiveTasks(): Promise {
+ const tasks = await fetchTasksFromApi();
+ return tasks.filter((task) => !isDoneStatus(task.status)).length;
+}
+
+export async function countTotalTasks(): Promise {
+ const tasks = await fetchTasksFromApi();
+ return tasks.length;
+}
+
+export async function getTaskStatusCounts(): Promise {
+ const tasks = await fetchTasksFromApi();
+ const counts: TaskStatusCounts = { open: 0, inProgress: 0, review: 0, done: 0, total: tasks.length };
+
+ for (const task of tasks) {
+ if (isOpenStatus(task.status)) counts.open++;
+ else if (task.status === "in-progress") counts.inProgress++;
+ else if (isReviewStatus(task.status)) counts.review++;
+ else if (task.status === "done") counts.done++;
}
return counts;
}
-/**
- * Count high priority tasks (high or urgent priority, not done)
- */
export async function countHighPriorityTasks(): Promise {
- const supabase = getServiceSupabase();
- const { count, error } = await supabase
- .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;
+ const tasks = await fetchTasksFromApi();
+ return tasks.filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status)).length;
}
-/**
- * Count overdue tasks (due date in the past, not done)
- */
export async function countOverdueTasks(): Promise {
- const supabase = getServiceSupabase();
- const today = new Date().toISOString().split("T")[0];
-
- 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;
+ const tasks = await fetchTasksFromApi();
+ const today = toDateOnly(new Date().toISOString());
+ return tasks.filter((task) => Boolean(task.dueDate) && !isDoneStatus(task.status) && toDateOnly(task.dueDate as string) < today).length;
}
-/**
- * Fetch recently updated tasks (last 5)
- */
export async function fetchRecentlyUpdatedTasks(limit = 5): Promise {
- const supabase = getServiceSupabase();
- const { data, error } = await supabase
- .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));
+ const tasks = await fetchTasksFromApi();
+ return [...tasks].sort(byUpdatedDesc).slice(0, limit);
}
-/**
- * Fetch recently completed tasks (last 5)
- */
export async function fetchRecentlyCompletedTasks(limit = 5): Promise {
- const supabase = getServiceSupabase();
- const { data, error } = await supabase
- .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));
+ const tasks = await fetchTasksFromApi();
+ return tasks.filter((task) => isDoneStatus(task.status)).sort(byUpdatedDesc).slice(0, limit);
}
-/**
- * Fetch high priority open tasks (top 5)
- */
export async function fetchHighPriorityOpenTasks(limit = 5): Promise {
- const supabase = getServiceSupabase();
- const { data, error } = await supabase
- .from("tasks")
- .select("*")
- .in("priority", ["high", "urgent"])
- .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));
+ const tasks = await fetchTasksFromApi();
+ return tasks
+ .filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status))
+ .sort(byUpdatedDesc)
+ .slice(0, limit);
}
diff --git a/package.json b/package.json
index a91fe65..b0afb40 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
- "lint": "eslint"
+ "lint": "eslint",
+ "test:cli-contract": "bash scripts/tests/reuse-gantt-cli-contract.sh"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/scripts/lib/gantt_cli.sh b/scripts/lib/gantt_cli.sh
new file mode 100755
index 0000000..7dbf7cc
--- /dev/null
+++ b/scripts/lib/gantt_cli.sh
@@ -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" "$@"
+}
diff --git a/scripts/project.sh b/scripts/project.sh
new file mode 100755
index 0000000..b0af2eb
--- /dev/null
+++ b/scripts/project.sh
@@ -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" "$@"
diff --git a/scripts/task.sh b/scripts/task.sh
new file mode 100755
index 0000000..8dcc652
--- /dev/null
+++ b/scripts/task.sh
@@ -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" "$@"
diff --git a/scripts/tests/reuse-gantt-cli-contract.sh b/scripts/tests/reuse-gantt-cli-contract.sh
new file mode 100755
index 0000000..bf73918
--- /dev/null
+++ b/scripts/tests/reuse-gantt-cli-contract.sh
@@ -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"
diff --git a/scripts/update-task-status.js b/scripts/update-task-status.js
index 74bd306..a1d343d 100644
--- a/scripts/update-task-status.js
+++ b/scripts/update-task-status.js
@@ -1,28 +1,28 @@
-const { createClient } = require('@supabase/supabase-js');
+#!/usr/bin/env node
-const supabaseUrl = 'https://qnatchrjlpehiijwtreh.supabase.co';
-const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuYXRjaHJqbHBlaGlpand0cmVoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE2NDA0MzYsImV4cCI6MjA4NzIxNjQzNn0.47XOMrQBzcQEh71phQflPoO4v79Jk3rft7BC72KHDvA';
+const { spawnSync } = require("node:child_process");
+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() {
- const taskId = '65bc8c5a-212a-4532-a027-ea424e62900e';
-
- 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);
- }
-
- console.log('Task updated successfully:', data);
+if (!taskId) {
+ console.error("Usage: TASK_ID= node scripts/update-task-status.js [taskId] [status]");
+ process.exit(1);
}
-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);