Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
865c11dd8b
commit
9ce8deb678
17
.env.example
Normal file
17
.env.example
Normal 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
2
.gitignore
vendored
@ -33,6 +33,8 @@ yarn-error.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.local.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
28
README.md
28
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:
|
||||
|
||||
42
app/podcast/page.tsx
Normal file
42
app/podcast/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
app/podcast/rss.xml/route.ts
Normal file
58
app/podcast/rss.xml/route.ts
Normal 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
50
lib/data/gantt-api.ts
Normal 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;
|
||||
}
|
||||
@ -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<ProgressMetric> {
|
||||
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<ProgressMetric> {
|
||||
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<ProgressMetric> {
|
||||
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<ProgressMetric> {
|
||||
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<ProgressMetric> {
|
||||
// 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<ProgressMetric> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get travel fund progress
|
||||
* Placeholder - will be replaced with actual savings tracking
|
||||
*/
|
||||
export async function getTravelFundProgress(): Promise<ProgressMetric> {
|
||||
// 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,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[]> {
|
||||
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
|
||||
// (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
|
||||
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";
|
||||
@ -310,7 +171,6 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
|
||||
category = "financial";
|
||||
}
|
||||
|
||||
// Determine icon based on category
|
||||
const iconMap: Record<Milestone["category"], string> = {
|
||||
ios: "Smartphone",
|
||||
financial: "DollarSign",
|
||||
@ -323,74 +183,39 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
|
||||
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<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 { data: tasks, error: tasksError } = await supabase
|
||||
.from("tasks")
|
||||
.select("*")
|
||||
.in("priority", ["high", "urgent"])
|
||||
.neq("status", "done")
|
||||
.order("due_date", { ascending: true })
|
||||
.limit(6);
|
||||
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);
|
||||
|
||||
if (tasksError) {
|
||||
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 => ({
|
||||
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<MissionProgress> {
|
||||
const [
|
||||
retirement,
|
||||
iosPortfolio,
|
||||
sideHustleRevenue,
|
||||
travelFund,
|
||||
milestones,
|
||||
nextSteps,
|
||||
] = await Promise.all([
|
||||
const [retirement, iosPortfolio, sideHustleRevenue, travelFund, milestones, nextSteps] = await Promise.all([
|
||||
getRetirementProgress(),
|
||||
getiOSPortfolioProgress(),
|
||||
getSideHustleRevenue(),
|
||||
|
||||
@ -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<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 {
|
||||
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<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[]> {
|
||||
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<GanttProjectsResponse>("/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<string, unknown>));
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total projects
|
||||
*/
|
||||
export async function countProjects(): Promise<number> {
|
||||
const supabase = getServiceSupabase();
|
||||
const { count, error } = await supabase
|
||||
.from("projects")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
if (error) {
|
||||
console.error("Error counting projects:", error);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return count || 0;
|
||||
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<string, unknown>): Sprint {
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
|
||||
status: isSprintStatus(row.status) ? row.status : "planning",
|
||||
startDate: toNonEmptyString(row.start_date) ?? new Date().toISOString(),
|
||||
endDate: toNonEmptyString(row.end_date) ?? new Date().toISOString(),
|
||||
projectId: String(row.project_id ?? ""),
|
||||
goal: toNonEmptyString(row.goal),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all sprints from Supabase
|
||||
*/
|
||||
export async function fetchAllSprints(): Promise<Sprint[]> {
|
||||
const supabase = getServiceSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from("sprints")
|
||||
.select("*")
|
||||
.order("start_date", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching sprints:", error);
|
||||
try {
|
||||
const response = await fetchGanttApi<GanttSprintsResponse>("/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<string, unknown>));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sprints for a specific project
|
||||
*/
|
||||
export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
|
||||
const supabase = getServiceSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from("sprints")
|
||||
.select("*")
|
||||
.eq("project_id", projectId)
|
||||
.order("start_date", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching project sprints:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
return (data || []).map((row) => mapSprintRow(row as Record<string, unknown>));
|
||||
const sprints = await fetchAllSprints();
|
||||
return sprints.filter((sprint) => sprint.projectId === projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active sprint
|
||||
*/
|
||||
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
||||
const supabase = getServiceSupabase();
|
||||
const { data, error } = await supabase
|
||||
.from("sprints")
|
||||
.select("*")
|
||||
.eq("status", "active")
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching active sprint:", error);
|
||||
try {
|
||||
const response = await fetchGanttApi<GanttCurrentSprintResponse>("/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<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count sprints by status
|
||||
*/
|
||||
export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> {
|
||||
const supabase = getServiceSupabase();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("sprints")
|
||||
.select("status");
|
||||
|
||||
if (error) {
|
||||
console.error("Error counting sprints:", error);
|
||||
return { planning: 0, active: 0, completed: 0, total: 0 };
|
||||
const 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,54 +176,11 @@ export function calculateProjectStats(project: Project, tasks: Task[]): ProjectS
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all projects with their statistics
|
||||
*/
|
||||
export async function fetchProjectsWithStats(): Promise<ProjectStats[]> {
|
||||
const supabase = getServiceSupabase();
|
||||
|
||||
// Fetch projects and tasks in parallel
|
||||
const [projectsResult, tasksResult] = await Promise.all([
|
||||
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
||||
supabase.from("tasks").select("*"),
|
||||
]);
|
||||
|
||||
if (projectsResult.error) {
|
||||
console.error("Error fetching projects:", projectsResult.error);
|
||||
throw new Error(`Failed to fetch projects: ${projectsResult.error.message}`);
|
||||
}
|
||||
|
||||
const projects = (projectsResult.data || []).map((row) => mapProjectRow(row as Record<string, unknown>));
|
||||
const tasks = (tasksResult.data || []).map((row) => {
|
||||
const fallbackDate = new Date().toISOString();
|
||||
return {
|
||||
id: String(row.id ?? ""),
|
||||
title: toNonEmptyString(row.title) ?? "",
|
||||
description: toNonEmptyString(row.description),
|
||||
type: (toNonEmptyString(row.type) as Task["type"]) ?? "task",
|
||||
status: (toNonEmptyString(row.status) as Task["status"]) ?? "todo",
|
||||
priority: (toNonEmptyString(row.priority) as Task["priority"]) ?? "medium",
|
||||
projectId: String(row.project_id ?? ""),
|
||||
sprintId: toNonEmptyString(row.sprint_id),
|
||||
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
||||
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
|
||||
createdById: toNonEmptyString(row.created_by_id),
|
||||
createdByName: toNonEmptyString(row.created_by_name),
|
||||
assigneeId: toNonEmptyString(row.assignee_id),
|
||||
assigneeName: toNonEmptyString(row.assignee_name),
|
||||
dueDate: toNonEmptyString(row.due_date),
|
||||
comments: Array.isArray(row.comments) ? row.comments : [],
|
||||
tags: Array.isArray(row.tags) ? row.tags.filter((tag: unknown): tag is string => typeof tag === "string") : [],
|
||||
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
||||
} as Task;
|
||||
});
|
||||
|
||||
return projects.map(project => calculateProjectStats(project, tasks));
|
||||
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;
|
||||
@ -268,23 +188,17 @@ export function getProjectHealth(stats: ProjectStats): {
|
||||
} {
|
||||
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",
|
||||
})}`;
|
||||
}
|
||||
|
||||
@ -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<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<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 {
|
||||
open: number;
|
||||
inProgress: number;
|
||||
@ -150,135 +39,94 @@ export interface TaskStatusCounts {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function getTaskStatusCounts(): Promise<TaskStatusCounts> {
|
||||
const supabase = getServiceSupabase();
|
||||
function isDoneStatus(status: Task["status"]): boolean {
|
||||
return status === "done";
|
||||
}
|
||||
|
||||
// Get all tasks and count by status
|
||||
const { data, error } = await supabase
|
||||
.from("tasks")
|
||||
.select("status");
|
||||
function isOpenStatus(status: Task["status"]): boolean {
|
||||
return status === "open" || status === "todo" || status === "blocked";
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching task status counts:", error);
|
||||
return { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
const counts = { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
|
||||
export async function fetchAllTasks(): Promise<Task[]> {
|
||||
return fetchTasksFromApi();
|
||||
}
|
||||
|
||||
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 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> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<Task[]> {
|
||||
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<string, unknown>));
|
||||
const tasks = await fetchTasksFromApi();
|
||||
return [...tasks].sort(byUpdatedDesc).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recently completed tasks (last 5)
|
||||
*/
|
||||
export async function fetchRecentlyCompletedTasks(limit = 5): Promise<Task[]> {
|
||||
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<string, unknown>));
|
||||
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<Task[]> {
|
||||
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<string, unknown>));
|
||||
const tasks = await fetchTasksFromApi();
|
||||
return tasks
|
||||
.filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status))
|
||||
.sort(byUpdatedDesc)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
30
scripts/lib/gantt_cli.sh
Executable file
30
scripts/lib/gantt_cli.sh
Executable 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
9
scripts/project.sh
Executable 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
9
scripts/task.sh
Executable 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" "$@"
|
||||
66
scripts/tests/reuse-gantt-cli-contract.sh
Executable file
66
scripts/tests/reuse-gantt-cli-contract.sh
Executable 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"
|
||||
@ -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=<uuid> 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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user