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 files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.local.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
28
README.md
28
README.md
@ -137,6 +137,7 @@ mission-control/
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
GANTT_API_BASE_URL= # ex: http://gantt-board:3000/api (Docker network)
|
||||||
|
|
||||||
# Optional
|
# Optional
|
||||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
||||||
@ -150,6 +151,9 @@ NEXT_PUBLIC_SUPABASE_SITE_URL=
|
|||||||
NEXT_PUBLIC_GOOGLE_URL=
|
NEXT_PUBLIC_GOOGLE_URL=
|
||||||
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
|
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
|
||||||
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
|
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
|
||||||
|
NEXT_PUBLIC_GANTT_API_BASE_URL= # Client-side override if needed
|
||||||
|
GANTT_API_BEARER_TOKEN= # Optional server-to-server auth header
|
||||||
|
GANTT_API_COOKIE= # Optional server-to-server cookie header
|
||||||
```
|
```
|
||||||
|
|
||||||
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
|
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
|
||||||
@ -170,6 +174,30 @@ npm run build
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## CLI Reuse (API Passthrough)
|
||||||
|
|
||||||
|
Mission Control reuses the existing task/project CLI from the sibling `gantt-board` project instead of duplicating command logic.
|
||||||
|
|
||||||
|
- `scripts/task.sh` delegates to `../gantt-board/scripts/task.sh`
|
||||||
|
- `scripts/project.sh` delegates to `../gantt-board/scripts/project.sh`
|
||||||
|
- `scripts/update-task-status.js` delegates to `gantt-board` `task.sh update`
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `GANTT_BOARD_DIR` (optional): override path to the `gantt-board` repo root.
|
||||||
|
- `API_URL` (optional): forwarded to reused CLI so all business logic stays in API endpoints.
|
||||||
|
|
||||||
|
Mission Control web data for tasks/projects/sprints is also read from `gantt-board` API via `GANTT_API_BASE_URL`, so both CLI and web use the same backend contract.
|
||||||
|
|
||||||
|
For separate Docker images, set `GANTT_API_BASE_URL` to the Docker service DNS name (example: `http://gantt-board:3000/api`) on the same Docker network.
|
||||||
|
If `gantt-board` API auth is required, provide either `GANTT_API_COOKIE` (session cookie) or `GANTT_API_BEARER_TOKEN` as server-to-server auth context.
|
||||||
|
|
||||||
|
Run passthrough contract test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:cli-contract
|
||||||
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
The app deploys automatically to Vercel on push to main:
|
The app deploys automatically to Vercel on push to main:
|
||||||
|
|||||||
42
app/podcast/page.tsx
Normal file
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 { Task, fetchAllTasks } from "./tasks";
|
||||||
import { Project, fetchAllProjects } from "./projects";
|
import { Project, fetchAllProjects } from "./projects";
|
||||||
import { getGanttTaskUrl } from "@/lib/config/sites";
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Types
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export interface ProgressMetric {
|
export interface ProgressMetric {
|
||||||
id: string;
|
id: string;
|
||||||
@ -45,90 +40,37 @@ export interface MissionProgress {
|
|||||||
nextSteps: NextStep[];
|
nextSteps: NextStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Constants
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
// Target goals
|
|
||||||
const TARGET_APPS = 20;
|
const TARGET_APPS = 20;
|
||||||
const TARGET_REVENUE = 10000; // $10K MRR target
|
const TARGET_REVENUE = 10000;
|
||||||
const TARGET_TRAVEL_FUND = 50000; // $50K travel fund
|
const TARGET_TRAVEL_FUND = 50000;
|
||||||
const TARGET_RETIREMENT_TASKS = 100; // Tasks completed as proxy for progress
|
const TARGET_RETIREMENT_TASKS = 100;
|
||||||
|
|
||||||
// iOS-related keywords for identifying app projects
|
|
||||||
const IOS_KEYWORDS = ["ios", "app", "swift", "mobile", "iphone", "ipad", "xcode"];
|
const IOS_KEYWORDS = ["ios", "app", "swift", "mobile", "iphone", "ipad", "xcode"];
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Helper Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a project is iOS-related
|
|
||||||
*/
|
|
||||||
function isIOSProject(project: Project): boolean {
|
function isIOSProject(project: Project): boolean {
|
||||||
const nameLower = project.name.toLowerCase();
|
const nameLower = project.name.toLowerCase();
|
||||||
const descLower = (project.description || "").toLowerCase();
|
const descLower = (project.description || "").toLowerCase();
|
||||||
return IOS_KEYWORDS.some(keyword =>
|
return IOS_KEYWORDS.some((keyword) => nameLower.includes(keyword) || descLower.includes(keyword));
|
||||||
nameLower.includes(keyword) || descLower.includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a task is iOS-related
|
|
||||||
*/
|
|
||||||
function isIOSTask(task: Task): boolean {
|
function isIOSTask(task: Task): boolean {
|
||||||
const titleLower = task.title.toLowerCase();
|
const titleLower = task.title.toLowerCase();
|
||||||
const descLower = (task.description || "").toLowerCase();
|
const descLower = (task.description || "").toLowerCase();
|
||||||
const hasIOSTag = task.tags.some(tag =>
|
const hasIOSTag = task.tags.some((tag) => ["ios", "app", "swift", "mobile"].includes(tag.toLowerCase()));
|
||||||
["ios", "app", "swift", "mobile"].includes(tag.toLowerCase())
|
return hasIOSTag || IOS_KEYWORDS.some((keyword) => titleLower.includes(keyword) || descLower.includes(keyword));
|
||||||
);
|
|
||||||
return hasIOSTag || IOS_KEYWORDS.some(keyword =>
|
|
||||||
titleLower.includes(keyword) || descLower.includes(keyword)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate percentage with bounds
|
|
||||||
*/
|
|
||||||
function calculatePercentage(current: number, target: number): number {
|
function calculatePercentage(current: number, target: number): number {
|
||||||
if (target === 0) return 0;
|
if (target === 0) return 0;
|
||||||
return Math.min(100, Math.max(0, Math.round((current / target) * 100)));
|
return Math.min(100, Math.max(0, Math.round((current / target) * 100)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function byUpdatedDesc(a: Task, b: Task): number {
|
||||||
* Format currency
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
*/
|
|
||||||
function formatCurrency(amount: number): string {
|
|
||||||
return new Intl.NumberFormat("en-US", {
|
|
||||||
style: "currency",
|
|
||||||
currency: "USD",
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Progress Metric Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get retirement progress based on completed tasks as a proxy
|
|
||||||
* In future, this could be based on actual financial data
|
|
||||||
*/
|
|
||||||
export async function getRetirementProgress(): Promise<ProgressMetric> {
|
export async function getRetirementProgress(): Promise<ProgressMetric> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchAllTasks();
|
||||||
|
const current = tasks.filter((task) => task.status === "done").length;
|
||||||
// Count completed tasks as a proxy for "progress toward freedom"
|
|
||||||
const { count: completedTasks, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.eq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error calculating retirement progress:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = completedTasks || 0;
|
|
||||||
const percentage = calculatePercentage(current, TARGET_RETIREMENT_TASKS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "retirement",
|
id: "retirement",
|
||||||
@ -136,34 +78,18 @@ export async function getRetirementProgress(): Promise<ProgressMetric> {
|
|||||||
current,
|
current,
|
||||||
target: TARGET_RETIREMENT_TASKS,
|
target: TARGET_RETIREMENT_TASKS,
|
||||||
unit: "tasks",
|
unit: "tasks",
|
||||||
percentage,
|
percentage: calculatePercentage(current, TARGET_RETIREMENT_TASKS),
|
||||||
icon: "Target",
|
icon: "Target",
|
||||||
color: "from-emerald-500 to-teal-500",
|
color: "from-emerald-500 to-teal-500",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get iOS portfolio progress
|
|
||||||
* Counts completed iOS projects and tasks
|
|
||||||
*/
|
|
||||||
export async function getiOSPortfolioProgress(): Promise<ProgressMetric> {
|
export async function getiOSPortfolioProgress(): Promise<ProgressMetric> {
|
||||||
const [projects, tasks] = await Promise.all([
|
const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
|
||||||
fetchAllProjects(),
|
|
||||||
fetchAllTasks(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Count iOS projects
|
|
||||||
const iosProjects = projects.filter(isIOSProject);
|
const iosProjects = projects.filter(isIOSProject);
|
||||||
|
const completedIOSTasks = tasks.filter((task) => isIOSTask(task) && task.status === "done").length;
|
||||||
// Count completed iOS tasks as additional progress
|
|
||||||
const completedIOSTasks = tasks.filter(t =>
|
|
||||||
isIOSTask(t) && t.status === "done"
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// Weight: 1 project = 1 app, 10 completed tasks = 1 app progress
|
|
||||||
const taskContribution = Math.floor(completedIOSTasks / 10);
|
const taskContribution = Math.floor(completedIOSTasks / 10);
|
||||||
const current = Math.min(iosProjects.length + taskContribution, TARGET_APPS);
|
const current = Math.min(iosProjects.length + taskContribution, TARGET_APPS);
|
||||||
const percentage = calculatePercentage(current, TARGET_APPS);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "ios-portfolio",
|
id: "ios-portfolio",
|
||||||
@ -171,42 +97,22 @@ export async function getiOSPortfolioProgress(): Promise<ProgressMetric> {
|
|||||||
current,
|
current,
|
||||||
target: TARGET_APPS,
|
target: TARGET_APPS,
|
||||||
unit: "apps",
|
unit: "apps",
|
||||||
percentage,
|
percentage: calculatePercentage(current, TARGET_APPS),
|
||||||
icon: "Smartphone",
|
icon: "Smartphone",
|
||||||
color: "from-blue-500 to-cyan-500",
|
color: "from-blue-500 to-cyan-500",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get side hustle revenue progress
|
|
||||||
* Placeholder - will be replaced with real revenue tracking
|
|
||||||
*/
|
|
||||||
export async function getSideHustleRevenue(): Promise<ProgressMetric> {
|
export async function getSideHustleRevenue(): Promise<ProgressMetric> {
|
||||||
// Placeholder: For now, calculate based on completed milestones
|
const tasks = await fetchAllTasks();
|
||||||
// In future, this will pull from actual revenue data
|
const doneTasks = tasks.filter((task) => task.status === "done");
|
||||||
const supabase = getServiceSupabase();
|
const milestones = doneTasks.filter((task) =>
|
||||||
|
task.tags.some((tag) => {
|
||||||
const { data: doneTasks, error } = await supabase
|
const normalized = tag.toLowerCase();
|
||||||
.from("tasks")
|
return normalized === "revenue" || normalized === "milestone";
|
||||||
.select("*")
|
})
|
||||||
.eq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching revenue milestones:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter for tasks with revenue or milestone tags
|
|
||||||
const milestones = (doneTasks || []).filter(task => {
|
|
||||||
const tags = (task.tags || []) as string[];
|
|
||||||
return tags.some(tag =>
|
|
||||||
tag.toLowerCase() === "revenue" ||
|
|
||||||
tag.toLowerCase() === "milestone"
|
|
||||||
);
|
);
|
||||||
});
|
const estimatedRevenue = milestones.length * 500;
|
||||||
|
|
||||||
// Estimate: each revenue milestone = $500 progress (placeholder logic)
|
|
||||||
const milestoneCount = milestones.length;
|
|
||||||
const estimatedRevenue = milestoneCount * 500;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: "revenue",
|
id: "revenue",
|
||||||
@ -220,30 +126,10 @@ export async function getSideHustleRevenue(): Promise<ProgressMetric> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get travel fund progress
|
|
||||||
* Placeholder - will be replaced with actual savings tracking
|
|
||||||
*/
|
|
||||||
export async function getTravelFundProgress(): Promise<ProgressMetric> {
|
export async function getTravelFundProgress(): Promise<ProgressMetric> {
|
||||||
// Placeholder: Calculate based on "travel" tagged completed tasks
|
const tasks = await fetchAllTasks();
|
||||||
const supabase = getServiceSupabase();
|
const doneTasks = tasks.filter((task) => task.status === "done");
|
||||||
|
const travelTasks = doneTasks.filter((task) => task.tags.some((tag) => tag.toLowerCase() === "travel"));
|
||||||
const { data: doneTasks, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.eq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching travel progress:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter for tasks with travel tag
|
|
||||||
const travelTasks = (doneTasks || []).filter(task => {
|
|
||||||
const tags = (task.tags || []) as string[];
|
|
||||||
return tags.some(tag => tag.toLowerCase() === "travel");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Estimate: each travel task = $500 saved (placeholder)
|
|
||||||
const estimatedSavings = travelTasks.length * 500;
|
const estimatedSavings = travelTasks.length * 500;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -258,47 +144,22 @@ export async function getTravelFundProgress(): Promise<ProgressMetric> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Milestones Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get mission milestones from completed tasks tagged with "milestone" or "mission"
|
|
||||||
*/
|
|
||||||
export async function getMissionMilestones(): Promise<Milestone[]> {
|
export async function getMissionMilestones(): Promise<Milestone[]> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchAllTasks();
|
||||||
|
const milestoneTasks = tasks
|
||||||
|
.filter((task) => task.status === "done")
|
||||||
|
.filter((task) =>
|
||||||
|
task.tags.some((tag) => {
|
||||||
|
const normalized = tag.toLowerCase();
|
||||||
|
return normalized === "milestone" || normalized === "mission";
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sort(byUpdatedDesc);
|
||||||
|
|
||||||
// Fetch all completed tasks and filter for tags in code
|
return milestoneTasks.map((task) => {
|
||||||
// (Supabase JSON filtering can be tricky with different setups)
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.eq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching mission milestones:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter for tasks with milestone or mission tags
|
|
||||||
const milestoneTasks = (data || []).filter(task => {
|
|
||||||
const tags = (task.tags || []) as string[];
|
|
||||||
return tags.some(tag =>
|
|
||||||
tag.toLowerCase() === "milestone" ||
|
|
||||||
tag.toLowerCase() === "mission"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by completion date (updated_at) descending
|
|
||||||
const sortedData = milestoneTasks.sort((a, b) =>
|
|
||||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return sortedData.map((task, index) => {
|
|
||||||
// Determine category based on tags and title
|
|
||||||
let category: Milestone["category"] = "business";
|
let category: Milestone["category"] = "business";
|
||||||
const titleLower = task.title.toLowerCase();
|
const titleLower = task.title.toLowerCase();
|
||||||
const tagsLower = (task.tags || []).map((t: string) => t.toLowerCase());
|
const tagsLower = task.tags.map((tag) => tag.toLowerCase());
|
||||||
|
|
||||||
if (tagsLower.includes("ios") || tagsLower.includes("app") || titleLower.includes("app")) {
|
if (tagsLower.includes("ios") || tagsLower.includes("app") || titleLower.includes("app")) {
|
||||||
category = "ios";
|
category = "ios";
|
||||||
@ -310,7 +171,6 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
|
|||||||
category = "financial";
|
category = "financial";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine icon based on category
|
|
||||||
const iconMap: Record<Milestone["category"], string> = {
|
const iconMap: Record<Milestone["category"], string> = {
|
||||||
ios: "Smartphone",
|
ios: "Smartphone",
|
||||||
financial: "DollarSign",
|
financial: "DollarSign",
|
||||||
@ -323,74 +183,39 @@ export async function getMissionMilestones(): Promise<Milestone[]> {
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
completedAt: task.updated_at,
|
completedAt: task.updatedAt,
|
||||||
category,
|
category,
|
||||||
icon: iconMap[category],
|
icon: iconMap[category],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Next Steps Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get next mission steps - high priority tasks that advance the mission
|
|
||||||
*/
|
|
||||||
export async function getNextMissionSteps(): Promise<NextStep[]> {
|
export async function getNextMissionSteps(): Promise<NextStep[]> {
|
||||||
const supabase = getServiceSupabase();
|
const [tasks, projects] = await Promise.all([fetchAllTasks(), fetchAllProjects()]);
|
||||||
|
const projectMap = new Map(projects.map((project) => [project.id, project.name]));
|
||||||
|
|
||||||
// Fetch high/urgent priority tasks that are not done
|
const prioritized = tasks
|
||||||
const { data: tasks, error: tasksError } = await supabase
|
.filter((task) => (task.priority === "high" || task.priority === "urgent") && task.status !== "done")
|
||||||
.from("tasks")
|
.sort((a, b) => {
|
||||||
.select("*")
|
const aDue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
.in("priority", ["high", "urgent"])
|
const bDue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
.neq("status", "done")
|
if (aDue !== bDue) return aDue - bDue;
|
||||||
.order("due_date", { ascending: true })
|
return byUpdatedDesc(a, b);
|
||||||
.limit(6);
|
})
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
if (tasksError) {
|
return prioritized.map((task) => ({
|
||||||
console.error("Error fetching next mission steps:", tasksError);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch projects for names
|
|
||||||
const { data: projects, error: projectsError } = await supabase
|
|
||||||
.from("projects")
|
|
||||||
.select("id, name");
|
|
||||||
|
|
||||||
if (projectsError) {
|
|
||||||
console.error("Error fetching projects:", projectsError);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectMap = new Map((projects || []).map(p => [p.id, p.name]));
|
|
||||||
|
|
||||||
return (tasks || []).map(task => ({
|
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
priority: task.priority as "high" | "urgent",
|
priority: task.priority as "high" | "urgent",
|
||||||
projectName: projectMap.get(task.project_id),
|
projectName: projectMap.get(task.projectId),
|
||||||
dueDate: task.due_date,
|
dueDate: task.dueDate,
|
||||||
ganttBoardUrl: getGanttTaskUrl(task.id),
|
ganttBoardUrl: getGanttTaskUrl(task.id),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Combined Fetch
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all mission progress data in parallel
|
|
||||||
*/
|
|
||||||
export async function fetchMissionProgress(): Promise<MissionProgress> {
|
export async function fetchMissionProgress(): Promise<MissionProgress> {
|
||||||
const [
|
const [retirement, iosPortfolio, sideHustleRevenue, travelFund, milestones, nextSteps] = await Promise.all([
|
||||||
retirement,
|
|
||||||
iosPortfolio,
|
|
||||||
sideHustleRevenue,
|
|
||||||
travelFund,
|
|
||||||
milestones,
|
|
||||||
nextSteps,
|
|
||||||
] = await Promise.all([
|
|
||||||
getRetirementProgress(),
|
getRetirementProgress(),
|
||||||
getiOSPortfolioProgress(),
|
getiOSPortfolioProgress(),
|
||||||
getSideHustleRevenue(),
|
getSideHustleRevenue(),
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { fetchGanttApi } from "@/lib/data/gantt-api";
|
||||||
import { Task } from "./tasks";
|
import { Task, fetchAllTasks } from "./tasks";
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string;
|
||||||
@ -30,173 +30,136 @@ export interface ProjectStats {
|
|||||||
recentTasks: Task[];
|
recentTasks: Task[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNonEmptyString(value: unknown): string | undefined {
|
interface GanttProjectsResponse {
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
projects?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapProjectRow(row: Record<string, unknown>): Project {
|
interface GanttSprintsResponse {
|
||||||
|
sprints?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: Sprint["status"];
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
project_id?: string;
|
||||||
|
projectId?: string;
|
||||||
|
goal?: string | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GanttCurrentSprintResponse {
|
||||||
|
sprint?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: Sprint["status"];
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
projectId: string;
|
||||||
|
goal?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProject(project: NonNullable<GanttProjectsResponse["projects"]>[number]): Project {
|
||||||
return {
|
return {
|
||||||
id: String(row.id ?? ""),
|
id: project.id,
|
||||||
name: toNonEmptyString(row.name) ?? "Untitled Project",
|
name: project.name,
|
||||||
description: toNonEmptyString(row.description),
|
description: project.description ?? undefined,
|
||||||
color: toNonEmptyString(row.color) ?? "#3b82f6",
|
color: project.color || "#3b82f6",
|
||||||
createdAt: toNonEmptyString(row.created_at) ?? new Date().toISOString(),
|
createdAt: project.createdAt || project.created_at || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapSprint(sprint: NonNullable<GanttSprintsResponse["sprints"]>[number]): Sprint {
|
||||||
|
return {
|
||||||
|
id: sprint.id,
|
||||||
|
name: sprint.name,
|
||||||
|
status: sprint.status,
|
||||||
|
startDate: sprint.startDate || sprint.start_date || new Date().toISOString(),
|
||||||
|
endDate: sprint.endDate || sprint.end_date || new Date().toISOString(),
|
||||||
|
projectId: sprint.projectId || sprint.project_id || "",
|
||||||
|
goal: sprint.goal ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all projects from Supabase
|
|
||||||
*/
|
|
||||||
export async function fetchAllProjects(): Promise<Project[]> {
|
export async function fetchAllProjects(): Promise<Project[]> {
|
||||||
const supabase = getServiceSupabase();
|
try {
|
||||||
const { data, error } = await supabase
|
const response = await fetchGanttApi<GanttProjectsResponse>("/projects");
|
||||||
.from("projects")
|
return Array.isArray(response.projects) ? response.projects.map(mapProject) : [];
|
||||||
.select("*")
|
} catch (error) {
|
||||||
.order("created_at", { ascending: true });
|
console.error("Error fetching projects from gantt-board API:", error);
|
||||||
|
return [];
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching projects:", error);
|
|
||||||
throw new Error(`Failed to fetch projects: ${error.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data || []).map((row) => mapProjectRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count total projects
|
|
||||||
*/
|
|
||||||
export async function countProjects(): Promise<number> {
|
export async function countProjects(): Promise<number> {
|
||||||
const supabase = getServiceSupabase();
|
const projects = await fetchAllProjects();
|
||||||
const { count, error } = await supabase
|
return projects.length;
|
||||||
.from("projects")
|
|
||||||
.select("*", { count: "exact", head: true });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error counting projects:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sprint status type
|
|
||||||
const SPRINT_STATUSES = ["planning", "active", "completed", "cancelled"] as const;
|
|
||||||
type SprintStatus = typeof SPRINT_STATUSES[number];
|
|
||||||
|
|
||||||
function isSprintStatus(value: unknown): value is SprintStatus {
|
|
||||||
return typeof value === "string" && SPRINT_STATUSES.includes(value as SprintStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapSprintRow(row: Record<string, unknown>): Sprint {
|
|
||||||
return {
|
|
||||||
id: String(row.id ?? ""),
|
|
||||||
name: toNonEmptyString(row.name) ?? "Untitled Sprint",
|
|
||||||
status: isSprintStatus(row.status) ? row.status : "planning",
|
|
||||||
startDate: toNonEmptyString(row.start_date) ?? new Date().toISOString(),
|
|
||||||
endDate: toNonEmptyString(row.end_date) ?? new Date().toISOString(),
|
|
||||||
projectId: String(row.project_id ?? ""),
|
|
||||||
goal: toNonEmptyString(row.goal),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all sprints from Supabase
|
|
||||||
*/
|
|
||||||
export async function fetchAllSprints(): Promise<Sprint[]> {
|
export async function fetchAllSprints(): Promise<Sprint[]> {
|
||||||
const supabase = getServiceSupabase();
|
try {
|
||||||
const { data, error } = await supabase
|
const response = await fetchGanttApi<GanttSprintsResponse>("/sprints");
|
||||||
.from("sprints")
|
return Array.isArray(response.sprints) ? response.sprints.map(mapSprint) : [];
|
||||||
.select("*")
|
} catch (error) {
|
||||||
.order("start_date", { ascending: false });
|
console.error("Error fetching sprints from gantt-board API:", error);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching sprints:", error);
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (data || []).map((row) => mapSprintRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch sprints for a specific project
|
|
||||||
*/
|
|
||||||
export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
|
export async function fetchProjectSprints(projectId: string): Promise<Sprint[]> {
|
||||||
const supabase = getServiceSupabase();
|
const sprints = await fetchAllSprints();
|
||||||
const { data, error } = await supabase
|
return sprints.filter((sprint) => sprint.projectId === projectId);
|
||||||
.from("sprints")
|
|
||||||
.select("*")
|
|
||||||
.eq("project_id", projectId)
|
|
||||||
.order("start_date", { ascending: false });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching project sprints:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapSprintRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch active sprint
|
|
||||||
*/
|
|
||||||
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
export async function fetchActiveSprint(): Promise<Sprint | null> {
|
||||||
const supabase = getServiceSupabase();
|
try {
|
||||||
const { data, error } = await supabase
|
const response = await fetchGanttApi<GanttCurrentSprintResponse>("/sprints/current");
|
||||||
.from("sprints")
|
if (!response.sprint) return null;
|
||||||
.select("*")
|
return {
|
||||||
.eq("status", "active")
|
id: response.sprint.id,
|
||||||
.maybeSingle();
|
name: response.sprint.name,
|
||||||
|
status: response.sprint.status,
|
||||||
if (error) {
|
startDate: response.sprint.startDate,
|
||||||
console.error("Error fetching active sprint:", error);
|
endDate: response.sprint.endDate,
|
||||||
|
projectId: response.sprint.projectId,
|
||||||
|
goal: response.sprint.goal ?? undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching current sprint from gantt-board API:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data ? mapSprintRow(data as Record<string, unknown>) : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count sprints by status
|
|
||||||
*/
|
|
||||||
export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> {
|
export async function countSprintsByStatus(): Promise<{ planning: number; active: number; completed: number; total: number }> {
|
||||||
const supabase = getServiceSupabase();
|
const sprints = await fetchAllSprints();
|
||||||
|
const counts = { planning: 0, active: 0, completed: 0, total: sprints.length };
|
||||||
const { data, error } = await supabase
|
for (const sprint of sprints) {
|
||||||
.from("sprints")
|
if (sprint.status === "planning") counts.planning++;
|
||||||
.select("status");
|
else if (sprint.status === "active") counts.active++;
|
||||||
|
else if (sprint.status === "completed") counts.completed++;
|
||||||
if (error) {
|
|
||||||
console.error("Error counting sprints:", error);
|
|
||||||
return { planning: 0, active: 0, completed: 0, total: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const counts = { planning: 0, active: 0, completed: 0, total: 0 };
|
|
||||||
|
|
||||||
for (const row of data || []) {
|
|
||||||
counts.total++;
|
|
||||||
const status = row.status;
|
|
||||||
if (status === "planning") counts.planning++;
|
|
||||||
else if (status === "active") counts.active++;
|
|
||||||
else if (status === "completed") counts.completed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate project statistics
|
|
||||||
*/
|
|
||||||
export function calculateProjectStats(project: Project, tasks: Task[]): ProjectStats {
|
export function calculateProjectStats(project: Project, tasks: Task[]): ProjectStats {
|
||||||
const projectTasks = tasks.filter(t => t.projectId === project.id);
|
const projectTasks = tasks.filter((task) => task.projectId === project.id);
|
||||||
const completedTasks = projectTasks.filter(t => t.status === "done");
|
const completedTasks = projectTasks.filter((task) => task.status === "done");
|
||||||
const inProgressTasks = projectTasks.filter(t => t.status === "in-progress");
|
const inProgressTasks = projectTasks.filter((task) => task.status === "in-progress");
|
||||||
const urgentTasks = projectTasks.filter(t => t.priority === "urgent" && t.status !== "done");
|
const urgentTasks = projectTasks.filter((task) => task.priority === "urgent" && task.status !== "done");
|
||||||
const highPriorityTasks = projectTasks.filter(t => t.priority === "high" && t.status !== "done");
|
const highPriorityTasks = projectTasks.filter((task) => task.priority === "high" && task.status !== "done");
|
||||||
|
|
||||||
const totalTasks = projectTasks.length;
|
const totalTasks = projectTasks.length;
|
||||||
const progress = totalTasks > 0 ? Math.round((completedTasks.length / totalTasks) * 100) : 0;
|
const progress = totalTasks > 0 ? Math.round((completedTasks.length / totalTasks) * 100) : 0;
|
||||||
|
|
||||||
// Get recent tasks (last 5 updated)
|
|
||||||
const recentTasks = [...projectTasks]
|
const recentTasks = [...projectTasks]
|
||||||
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
@ -213,54 +176,11 @@ export function calculateProjectStats(project: Project, tasks: Task[]): ProjectS
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all projects with their statistics
|
|
||||||
*/
|
|
||||||
export async function fetchProjectsWithStats(): Promise<ProjectStats[]> {
|
export async function fetchProjectsWithStats(): Promise<ProjectStats[]> {
|
||||||
const supabase = getServiceSupabase();
|
const [projects, tasks] = await Promise.all([fetchAllProjects(), fetchAllTasks()]);
|
||||||
|
return projects.map((project) => calculateProjectStats(project, tasks));
|
||||||
// Fetch projects and tasks in parallel
|
|
||||||
const [projectsResult, tasksResult] = await Promise.all([
|
|
||||||
supabase.from("projects").select("*").order("created_at", { ascending: true }),
|
|
||||||
supabase.from("tasks").select("*"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (projectsResult.error) {
|
|
||||||
console.error("Error fetching projects:", projectsResult.error);
|
|
||||||
throw new Error(`Failed to fetch projects: ${projectsResult.error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projects = (projectsResult.data || []).map((row) => mapProjectRow(row as Record<string, unknown>));
|
|
||||||
const tasks = (tasksResult.data || []).map((row) => {
|
|
||||||
const fallbackDate = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
id: String(row.id ?? ""),
|
|
||||||
title: toNonEmptyString(row.title) ?? "",
|
|
||||||
description: toNonEmptyString(row.description),
|
|
||||||
type: (toNonEmptyString(row.type) as Task["type"]) ?? "task",
|
|
||||||
status: (toNonEmptyString(row.status) as Task["status"]) ?? "todo",
|
|
||||||
priority: (toNonEmptyString(row.priority) as Task["priority"]) ?? "medium",
|
|
||||||
projectId: String(row.project_id ?? ""),
|
|
||||||
sprintId: toNonEmptyString(row.sprint_id),
|
|
||||||
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
|
||||||
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
|
|
||||||
createdById: toNonEmptyString(row.created_by_id),
|
|
||||||
createdByName: toNonEmptyString(row.created_by_name),
|
|
||||||
assigneeId: toNonEmptyString(row.assignee_id),
|
|
||||||
assigneeName: toNonEmptyString(row.assignee_name),
|
|
||||||
dueDate: toNonEmptyString(row.due_date),
|
|
||||||
comments: Array.isArray(row.comments) ? row.comments : [],
|
|
||||||
tags: Array.isArray(row.tags) ? row.tags.filter((tag: unknown): tag is string => typeof tag === "string") : [],
|
|
||||||
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
|
||||||
} as Task;
|
|
||||||
});
|
|
||||||
|
|
||||||
return projects.map(project => calculateProjectStats(project, tasks));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get project health status based on various metrics
|
|
||||||
*/
|
|
||||||
export function getProjectHealth(stats: ProjectStats): {
|
export function getProjectHealth(stats: ProjectStats): {
|
||||||
status: "healthy" | "warning" | "critical";
|
status: "healthy" | "warning" | "critical";
|
||||||
label: string;
|
label: string;
|
||||||
@ -268,23 +188,17 @@ export function getProjectHealth(stats: ProjectStats): {
|
|||||||
} {
|
} {
|
||||||
const { progress, urgentTasks, highPriorityTasks, totalTasks } = stats;
|
const { progress, urgentTasks, highPriorityTasks, totalTasks } = stats;
|
||||||
|
|
||||||
// Critical: Has urgent tasks or no progress with many open tasks
|
|
||||||
if (urgentTasks > 0 || (totalTasks > 5 && progress === 0)) {
|
if (urgentTasks > 0 || (totalTasks > 5 && progress === 0)) {
|
||||||
return { status: "critical", label: "Needs Attention", color: "text-red-500" };
|
return { status: "critical", label: "Needs Attention", color: "text-red-500" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning: Has high priority tasks or low progress
|
|
||||||
if (highPriorityTasks > 2 || (totalTasks > 10 && progress < 30)) {
|
if (highPriorityTasks > 2 || (totalTasks > 10 && progress < 30)) {
|
||||||
return { status: "warning", label: "At Risk", color: "text-yellow-500" };
|
return { status: "warning", label: "At Risk", color: "text-yellow-500" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Healthy: Good progress, no urgent issues
|
|
||||||
return { status: "healthy", label: "On Track", color: "text-green-500" };
|
return { status: "healthy", label: "On Track", color: "text-green-500" };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate days remaining in sprint
|
|
||||||
*/
|
|
||||||
export function getSprintDaysRemaining(sprint: Sprint): number {
|
export function getSprintDaysRemaining(sprint: Sprint): number {
|
||||||
const endDate = new Date(sprint.endDate);
|
const endDate = new Date(sprint.endDate);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@ -292,11 +206,11 @@ export function getSprintDaysRemaining(sprint: Sprint): number {
|
|||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format sprint date range
|
|
||||||
*/
|
|
||||||
export function formatSprintDateRange(sprint: Sprint): string {
|
export function formatSprintDateRange(sprint: Sprint): string {
|
||||||
const start = new Date(sprint.startDate);
|
const start = new Date(sprint.startDate);
|
||||||
const end = new Date(sprint.endDate);
|
const end = new Date(sprint.endDate);
|
||||||
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric" })}`;
|
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { fetchAllProjects, countProjects } from "./projects";
|
import { fetchAllProjects, countProjects } from "./projects";
|
||||||
import { countActiveTasks, countTotalTasks } from "./tasks";
|
import { countActiveTasks, countTotalTasks, fetchAllTasks } from "./tasks";
|
||||||
|
|
||||||
export interface DashboardStats {
|
export interface DashboardStats {
|
||||||
activeTasksCount: number;
|
activeTasksCount: number;
|
||||||
@ -32,24 +32,13 @@ function calculateYearProgress(): { progress: number; day: number; totalDays: nu
|
|||||||
* For now, this uses a simple heuristic based on done tasks vs total tasks
|
* For now, this uses a simple heuristic based on done tasks vs total tasks
|
||||||
*/
|
*/
|
||||||
async function calculateGoalsProgress(): Promise<number> {
|
async function calculateGoalsProgress(): Promise<number> {
|
||||||
const supabase = (await import("@/lib/supabase/client")).getServiceSupabase();
|
const tasks = await fetchAllTasks();
|
||||||
|
if (tasks.length === 0) {
|
||||||
// Get done tasks count
|
|
||||||
const { count: doneCount, error: doneError } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.eq("status", "done");
|
|
||||||
|
|
||||||
// Get total tasks count
|
|
||||||
const { count: totalCount, error: totalError } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true });
|
|
||||||
|
|
||||||
if (doneError || totalError || !totalCount || totalCount === 0) {
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.round(((doneCount || 0) / totalCount) * 100);
|
const doneCount = tasks.filter((task) => task.status === "done").length;
|
||||||
|
return Math.round((doneCount / tasks.length) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
import { fetchGanttApi } from "@/lib/data/gantt-api";
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@ -27,121 +27,10 @@ export interface Task {
|
|||||||
attachments: unknown[];
|
attachments: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_STATUSES: Task["status"][] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"];
|
interface GanttTasksResponse {
|
||||||
|
tasks?: Task[];
|
||||||
function isTaskStatus(value: unknown): value is Task["status"] {
|
|
||||||
return typeof value === "string" && TASK_STATUSES.includes(value as Task["status"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNonEmptyString(value: unknown): string | undefined {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTaskRow(row: Record<string, unknown>): Task {
|
|
||||||
const fallbackDate = new Date().toISOString();
|
|
||||||
return {
|
|
||||||
id: String(row.id ?? ""),
|
|
||||||
title: toNonEmptyString(row.title) ?? "",
|
|
||||||
description: toNonEmptyString(row.description),
|
|
||||||
type: (toNonEmptyString(row.type) as Task["type"]) ?? "task",
|
|
||||||
status: isTaskStatus(row.status) ? row.status : "todo",
|
|
||||||
priority: (toNonEmptyString(row.priority) as Task["priority"]) ?? "medium",
|
|
||||||
projectId: String(row.project_id ?? ""),
|
|
||||||
sprintId: toNonEmptyString(row.sprint_id),
|
|
||||||
createdAt: toNonEmptyString(row.created_at) ?? fallbackDate,
|
|
||||||
updatedAt: toNonEmptyString(row.updated_at) ?? fallbackDate,
|
|
||||||
createdById: toNonEmptyString(row.created_by_id),
|
|
||||||
createdByName: toNonEmptyString(row.created_by_name),
|
|
||||||
createdByAvatarUrl: toNonEmptyString(row.created_by_avatar_url),
|
|
||||||
updatedById: toNonEmptyString(row.updated_by_id),
|
|
||||||
updatedByName: toNonEmptyString(row.updated_by_name),
|
|
||||||
updatedByAvatarUrl: toNonEmptyString(row.updated_by_avatar_url),
|
|
||||||
assigneeId: toNonEmptyString(row.assignee_id),
|
|
||||||
assigneeName: toNonEmptyString(row.assignee_name),
|
|
||||||
assigneeEmail: toNonEmptyString(row.assignee_email),
|
|
||||||
assigneeAvatarUrl: toNonEmptyString(row.assignee_avatar_url),
|
|
||||||
dueDate: toNonEmptyString(row.due_date),
|
|
||||||
comments: Array.isArray(row.comments) ? row.comments : [],
|
|
||||||
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
|
||||||
attachments: Array.isArray(row.attachments) ? row.attachments : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch all tasks from Supabase
|
|
||||||
*/
|
|
||||||
export async function fetchAllTasks(): Promise<Task[]> {
|
|
||||||
const supabase = getServiceSupabase();
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.order("created_at", { ascending: true });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching tasks:", error);
|
|
||||||
throw new Error(`Failed to fetch tasks: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch active tasks (status != 'done')
|
|
||||||
*/
|
|
||||||
export async function fetchActiveTasks(): Promise<Task[]> {
|
|
||||||
const supabase = getServiceSupabase();
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.neq("status", "done")
|
|
||||||
.order("created_at", { ascending: true });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching active tasks:", error);
|
|
||||||
throw new Error(`Failed to fetch active tasks: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count active tasks
|
|
||||||
*/
|
|
||||||
export async function countActiveTasks(): Promise<number> {
|
|
||||||
const supabase = getServiceSupabase();
|
|
||||||
const { count, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.neq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error counting active tasks:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count total tasks
|
|
||||||
*/
|
|
||||||
export async function countTotalTasks(): Promise<number> {
|
|
||||||
const supabase = getServiceSupabase();
|
|
||||||
const { count, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true });
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error counting total tasks:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get task counts by status
|
|
||||||
*/
|
|
||||||
export interface TaskStatusCounts {
|
export interface TaskStatusCounts {
|
||||||
open: number;
|
open: number;
|
||||||
inProgress: number;
|
inProgress: number;
|
||||||
@ -150,135 +39,94 @@ export interface TaskStatusCounts {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDoneStatus(status: Task["status"]): boolean {
|
||||||
|
return status === "done";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenStatus(status: Task["status"]): boolean {
|
||||||
|
return status === "open" || status === "todo" || status === "blocked";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReviewStatus(status: Task["status"]): boolean {
|
||||||
|
return status === "review" || status === "validate";
|
||||||
|
}
|
||||||
|
|
||||||
|
function byUpdatedDesc(a: Task, b: Task): number {
|
||||||
|
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOnly(value: string): string {
|
||||||
|
return value.split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTasksFromApi(): Promise<Task[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetchGanttApi<GanttTasksResponse>("/tasks?scope=all&include=detail");
|
||||||
|
return Array.isArray(response.tasks) ? response.tasks : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tasks from gantt-board API:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAllTasks(): Promise<Task[]> {
|
||||||
|
return fetchTasksFromApi();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchActiveTasks(): Promise<Task[]> {
|
||||||
|
const tasks = await fetchTasksFromApi();
|
||||||
|
return tasks.filter((task) => !isDoneStatus(task.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countActiveTasks(): Promise<number> {
|
||||||
|
const tasks = await fetchTasksFromApi();
|
||||||
|
return tasks.filter((task) => !isDoneStatus(task.status)).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countTotalTasks(): Promise<number> {
|
||||||
|
const tasks = await fetchTasksFromApi();
|
||||||
|
return tasks.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTaskStatusCounts(): Promise<TaskStatusCounts> {
|
export async function getTaskStatusCounts(): Promise<TaskStatusCounts> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
|
const counts: TaskStatusCounts = { open: 0, inProgress: 0, review: 0, done: 0, total: tasks.length };
|
||||||
|
|
||||||
// Get all tasks and count by status
|
for (const task of tasks) {
|
||||||
const { data, error } = await supabase
|
if (isOpenStatus(task.status)) counts.open++;
|
||||||
.from("tasks")
|
else if (task.status === "in-progress") counts.inProgress++;
|
||||||
.select("status");
|
else if (isReviewStatus(task.status)) counts.review++;
|
||||||
|
else if (task.status === "done") counts.done++;
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching task status counts:", error);
|
|
||||||
return { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const counts = { open: 0, inProgress: 0, review: 0, done: 0, total: 0 };
|
|
||||||
|
|
||||||
for (const row of data || []) {
|
|
||||||
counts.total++;
|
|
||||||
const status = row.status;
|
|
||||||
if (status === "open" || status === "todo" || status === "blocked") {
|
|
||||||
counts.open++;
|
|
||||||
} else if (status === "in-progress") {
|
|
||||||
counts.inProgress++;
|
|
||||||
} else if (status === "review" || status === "validate") {
|
|
||||||
counts.review++;
|
|
||||||
} else if (status === "done") {
|
|
||||||
counts.done++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count high priority tasks (high or urgent priority, not done)
|
|
||||||
*/
|
|
||||||
export async function countHighPriorityTasks(): Promise<number> {
|
export async function countHighPriorityTasks(): Promise<number> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
const { count, error } = await supabase
|
return tasks.filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status)).length;
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.in("priority", ["high", "urgent"])
|
|
||||||
.neq("status", "done");
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error counting high priority tasks:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Count overdue tasks (due date in the past, not done)
|
|
||||||
*/
|
|
||||||
export async function countOverdueTasks(): Promise<number> {
|
export async function countOverdueTasks(): Promise<number> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = toDateOnly(new Date().toISOString());
|
||||||
|
return tasks.filter((task) => Boolean(task.dueDate) && !isDoneStatus(task.status) && toDateOnly(task.dueDate as string) < today).length;
|
||||||
const { count, error } = await supabase
|
|
||||||
.from("tasks")
|
|
||||||
.select("*", { count: "exact", head: true })
|
|
||||||
.lt("due_date", today)
|
|
||||||
.neq("status", "done")
|
|
||||||
.not("due_date", "is", null);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error counting overdue tasks:", error);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return count || 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch recently updated tasks (last 5)
|
|
||||||
*/
|
|
||||||
export async function fetchRecentlyUpdatedTasks(limit = 5): Promise<Task[]> {
|
export async function fetchRecentlyUpdatedTasks(limit = 5): Promise<Task[]> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
const { data, error } = await supabase
|
return [...tasks].sort(byUpdatedDesc).slice(0, limit);
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.order("updated_at", { ascending: false })
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching recently updated tasks:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch recently completed tasks (last 5)
|
|
||||||
*/
|
|
||||||
export async function fetchRecentlyCompletedTasks(limit = 5): Promise<Task[]> {
|
export async function fetchRecentlyCompletedTasks(limit = 5): Promise<Task[]> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
const { data, error } = await supabase
|
return tasks.filter((task) => isDoneStatus(task.status)).sort(byUpdatedDesc).slice(0, limit);
|
||||||
.from("tasks")
|
|
||||||
.select("*")
|
|
||||||
.eq("status", "done")
|
|
||||||
.order("updated_at", { ascending: false })
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching recently completed tasks:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch high priority open tasks (top 5)
|
|
||||||
*/
|
|
||||||
export async function fetchHighPriorityOpenTasks(limit = 5): Promise<Task[]> {
|
export async function fetchHighPriorityOpenTasks(limit = 5): Promise<Task[]> {
|
||||||
const supabase = getServiceSupabase();
|
const tasks = await fetchTasksFromApi();
|
||||||
const { data, error } = await supabase
|
return tasks
|
||||||
.from("tasks")
|
.filter((task) => (task.priority === "high" || task.priority === "urgent") && !isDoneStatus(task.status))
|
||||||
.select("*")
|
.sort(byUpdatedDesc)
|
||||||
.in("priority", ["high", "urgent"])
|
.slice(0, limit);
|
||||||
.neq("status", "done")
|
|
||||||
.order("updated_at", { ascending: false })
|
|
||||||
.limit(limit);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error("Error fetching high priority open tasks:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return (data || []).map((row) => mapTaskRow(row as Record<string, unknown>));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev -p 3001",
|
"dev": "next dev -p 3001",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test:cli-contract": "bash scripts/tests/reuse-gantt-cli-contract.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
30
scripts/lib/gantt_cli.sh
Executable file
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 { spawnSync } = require("node:child_process");
|
||||||
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFuYXRjaHJqbHBlaGlpand0cmVoIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzE2NDA0MzYsImV4cCI6MjA4NzIxNjQzNn0.47XOMrQBzcQEh71phQflPoO4v79Jk3rft7BC72KHDvA';
|
const path = require("node:path");
|
||||||
|
|
||||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
const taskId = process.env.TASK_ID || process.argv[2];
|
||||||
|
const status = process.env.TASK_STATUS || process.argv[3] || "review";
|
||||||
|
const ganttBoardDir =
|
||||||
|
process.env.GANTT_BOARD_DIR ||
|
||||||
|
path.resolve(__dirname, "..", "..", "..", "gantt-board");
|
||||||
|
const taskCliPath = path.join(ganttBoardDir, "scripts", "task.sh");
|
||||||
|
|
||||||
async function updateTaskStatus() {
|
if (!taskId) {
|
||||||
const taskId = '65bc8c5a-212a-4532-a027-ea424e62900e';
|
console.error("Usage: TASK_ID=<uuid> node scripts/update-task-status.js [taskId] [status]");
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('tasks')
|
|
||||||
.update({
|
|
||||||
status: 'review',
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
.eq('id', taskId)
|
|
||||||
.select();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error updating task:', error);
|
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Task updated successfully:', data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTaskStatus();
|
const result = spawnSync(taskCliPath, ["update", taskId, "--status", status], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
console.error("Failed to execute gantt-board task CLI:", result.error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user