diff --git a/apps/delta-command/README.md b/apps/delta-command/README.md
index aadb763..d956a13 100644
--- a/apps/delta-command/README.md
+++ b/apps/delta-command/README.md
@@ -46,6 +46,28 @@ The whole app is small on purpose:
| `DELTA_API_URL` | `http://127.0.0.1:8000` | Backend URL (used by Next.js server-side and rewrites). |
| `DELTA_DATA_PATH` | `backend/config/data.json` | JSON database file. |
| `DELTA_HOST` / `DELTA_PORT` | `127.0.0.1` / `8000` | Backend bind address. |
+| `LLM_API_KEY` | *(unset)* | Enables the **AI Executive Briefing** on the dashboard. |
+| `LLM_BASE_URL` | `https://api.openai.com/v1` | Any OpenAI-compatible endpoint (Groq, Ollama, etc.). |
+| `LLM_MODEL` | `gpt-4o-mini` | Model name. |
+
+### AI briefing
+
+With `LLM_API_KEY` set, a **Generate** button on the dashboard produces a 2–3 paragraph leadership summary of the live pipeline, capacity, and delivery risks. Works with any OpenAI-compatible provider:
+
+```bash
+# OpenAI
+export LLM_API_KEY=sk-...
+
+# Groq
+export LLM_API_KEY=gsk_...
+export LLM_BASE_URL=https://api.groq.com/openai/v1
+export LLM_MODEL=llama-3.1-70b-versatile
+
+# Local Ollama
+export LLM_API_KEY=ollama
+export LLM_BASE_URL=http://localhost:11434/v1
+export LLM_MODEL=llama3.2
+```
## Production
diff --git a/apps/delta-command/backend/README.md b/apps/delta-command/backend/README.md
index fe33b3b..e178084 100644
--- a/apps/delta-command/backend/README.md
+++ b/apps/delta-command/backend/README.md
@@ -18,6 +18,19 @@ uv run delta-command # → http://127.0.0.1:8000
| PATCH | `/api/opportunities` | Update stage (probability auto-set to 100/0 for won/lost). |
| PATCH | `/api/projects` | Update status. |
| PATCH | `/api/timeline` | Update one weekly utilization cell for one engineer; recomputes engineer.utilization and status. |
+| POST | `/api/briefing` | Author an executive briefing via an OpenAI-compatible LLM. Returns `503` if `LLM_API_KEY` is not set. |
+
+### LLM configuration
+
+`POST /api/briefing` uses an OpenAI-compatible chat completions endpoint:
+
+```
+LLM_API_KEY required
+LLM_BASE_URL default https://api.openai.com/v1
+LLM_MODEL default gpt-4o-mini
+```
+
+Works with OpenAI, Groq, Ollama, or any provider that speaks `/v1/chat/completions`.
## Database
@@ -40,5 +53,6 @@ delta_command/
├── json_db.py # atomic file I/O
├── models.py # Pydantic models (snake_case in Python, camelCase in JSON)
├── store.py # domain operations (load/save/update)
+├── briefing.py # LLM-authored executive briefing
└── main.py # FastAPI routes
```
diff --git a/apps/delta-command/backend/pyproject.toml b/apps/delta-command/backend/pyproject.toml
index 3bdbe90..4084e6d 100644
--- a/apps/delta-command/backend/pyproject.toml
+++ b/apps/delta-command/backend/pyproject.toml
@@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"pydantic>=2.10.0",
+ "httpx>=0.28.0",
]
[project.scripts]
@@ -22,7 +23,6 @@ packages = ["src/delta_command"]
[dependency-groups]
dev = [
- "httpx>=0.28.0",
"pytest>=8.3.0",
"ruff>=0.11.0",
]
diff --git a/apps/delta-command/backend/src/delta_command/briefing.py b/apps/delta-command/backend/src/delta_command/briefing.py
new file mode 100644
index 0000000..c70f8c7
--- /dev/null
+++ b/apps/delta-command/backend/src/delta_command/briefing.py
@@ -0,0 +1,118 @@
+"""LLM-generated executive briefings.
+
+The backend summarises the current state into a compact prompt, then calls an
+OpenAI-compatible chat completions endpoint. Works with OpenAI, Groq, Ollama,
+etc. — anything that speaks the /v1/chat/completions protocol.
+
+Configure via environment:
+ LLM_API_KEY required (use any placeholder for local Ollama)
+ LLM_BASE_URL default: https://api.openai.com/v1
+ LLM_MODEL default: gpt-4o-mini
+"""
+
+import os
+
+import httpx
+
+from delta_command.models import Database, OpportunityStage, ProjectStatus
+from delta_command.store import load_database
+
+SYSTEM_PROMPT = (
+ "You are an executive briefing assistant for a delta engineering operations "
+ "dashboard. Given the current state, write a concise leadership briefing "
+ "(2-3 short paragraphs, ~150 words total) that highlights: pipeline health, "
+ "team capacity concerns, and delivery risks. Lead with what needs attention. "
+ "Use plain prose, no bullet lists, no headings."
+)
+
+
+class LLMNotConfigured(RuntimeError):
+ """Raised when LLM_API_KEY is missing."""
+
+
+class LLMError(RuntimeError):
+ """Raised when the upstream provider fails."""
+
+
+def build_context(db: Database) -> str:
+ """Compact state summary used as the LLM user prompt."""
+ engineers = db.engineers
+ opps = db.opportunities
+ projects = db.projects
+
+ active_opps = [o for o in opps if o.stage not in (OpportunityStage.WON, OpportunityStage.LOST)]
+ late_stage = [o for o in active_opps if o.stage in (OpportunityStage.PROPOSAL, OpportunityStage.NEGOTIATION)]
+ total_pipeline = sum(o.value for o in active_opps)
+ weighted = sum(o.value * o.probability / 100 for o in active_opps)
+
+ avg_util = round(sum(e.utilization for e in engineers) / len(engineers)) if engineers else 0
+ overallocated = sorted(
+ (e for e in engineers if e.utilization >= 100),
+ key=lambda e: -e.utilization,
+ )
+ unassigned = [e for e in engineers if not e.current_projects]
+
+ active_projects = [p for p in projects if p.status in (ProjectStatus.ACTIVE, ProjectStatus.AT_RISK)]
+ at_risk = [p for p in projects if p.status == ProjectStatus.AT_RISK]
+ total_budget = sum(p.budget for p in active_projects) or 1
+ total_spent = sum(p.spent for p in active_projects)
+
+ lines = [
+ f"Team: {len(engineers)} engineers, avg utilization {avg_util}%.",
+ f"Overallocated ({len(overallocated)}): "
+ + (", ".join(f"{e.name} at {e.utilization}%" for e in overallocated[:5]) or "none"),
+ f"Available for assignment: {len(unassigned)}.",
+ "",
+ f"Pipeline: {len(active_opps)} active opportunities worth ${total_pipeline/1_000_000:.1f}M "
+ f"(${weighted/1_000_000:.1f}M weighted). {len(late_stage)} in proposal or negotiation.",
+ "",
+ f"Delivery: {len(active_projects)} projects active, {len(at_risk)} at risk. "
+ f"Budget burn: ${total_spent/1_000_000:.1f}M of ${total_budget/1_000_000:.1f}M "
+ f"({round(total_spent/total_budget*100)}%).",
+ ]
+ if at_risk:
+ lines.append(
+ "At-risk projects: "
+ + ", ".join(f"{p.name} ({p.progress}% complete)" for p in at_risk[:3])
+ )
+ return "\n".join(lines)
+
+
+async def call_llm(system: str, user: str) -> str:
+ api_key = os.environ.get("LLM_API_KEY")
+ if not api_key:
+ raise LLMNotConfigured(
+ "LLM_API_KEY is not set. Configure an OpenAI-compatible provider "
+ "(see backend/README.md)."
+ )
+ base_url = os.environ.get("LLM_BASE_URL", "https://api.openai.com/v1").rstrip("/")
+ model = os.environ.get("LLM_MODEL", "gpt-4o-mini")
+
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ try:
+ response = await client.post(
+ f"{base_url}/chat/completions",
+ headers={"Authorization": f"Bearer {api_key}"},
+ json={
+ "model": model,
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user},
+ ],
+ "temperature": 0.4,
+ },
+ )
+ except httpx.HTTPError as exc:
+ raise LLMError(f"LLM request failed: {exc}") from exc
+
+ if response.status_code >= 400:
+ raise LLMError(f"LLM provider returned {response.status_code}: {response.text[:200]}")
+
+ try:
+ return response.json()["choices"][0]["message"]["content"].strip()
+ except (KeyError, IndexError, ValueError) as exc:
+ raise LLMError(f"Unexpected LLM response shape: {exc}") from exc
+
+
+async def generate_briefing() -> str:
+ return await call_llm(SYSTEM_PROMPT, build_context(load_database()))
diff --git a/apps/delta-command/backend/src/delta_command/main.py b/apps/delta-command/backend/src/delta_command/main.py
index 3d87e9b..587693b 100644
--- a/apps/delta-command/backend/src/delta_command/main.py
+++ b/apps/delta-command/backend/src/delta_command/main.py
@@ -5,6 +5,7 @@
import uvicorn
from fastapi import FastAPI, HTTPException
+from delta_command.briefing import LLMError, LLMNotConfigured, generate_briefing
from delta_command.models import (
Database,
Opportunity,
@@ -20,7 +21,7 @@
update_timeline_cell,
)
-app = FastAPI(title="Delta Command API", version="1.0.0")
+app = FastAPI(title="Delta Command API", version="1.1.0")
@app.get("/api/state")
@@ -53,6 +54,18 @@ def patch_timeline(body: TimelineCellUpdate) -> Database:
raise HTTPException(404, str(exc)) from exc
+@app.post("/api/briefing")
+async def post_briefing() -> dict[str, str]:
+ """Generate an LLM-authored executive briefing from the current state."""
+ try:
+ briefing = await generate_briefing()
+ except LLMNotConfigured as exc:
+ raise HTTPException(503, str(exc)) from exc
+ except LLMError as exc:
+ raise HTTPException(502, str(exc)) from exc
+ return {"briefing": briefing}
+
+
def run() -> None:
host = os.environ.get("DELTA_HOST", "127.0.0.1")
port = int(os.environ.get("DELTA_PORT", "8000"))
diff --git a/apps/delta-command/backend/tests/test_briefing.py b/apps/delta-command/backend/tests/test_briefing.py
new file mode 100644
index 0000000..cbe7116
--- /dev/null
+++ b/apps/delta-command/backend/tests/test_briefing.py
@@ -0,0 +1,88 @@
+from pathlib import Path
+from typing import Any
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+
+from delta_command.briefing import build_context
+from delta_command.main import app
+from delta_command.models import Database
+
+
+@pytest.fixture
+def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
+ data_file = tmp_path / "data.json"
+ source = Path(__file__).resolve().parents[1] / "config" / "data.json"
+ data_file.write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
+ monkeypatch.setenv("DELTA_DATA_PATH", str(data_file))
+ monkeypatch.delenv("LLM_API_KEY", raising=False)
+ return TestClient(app)
+
+
+def test_build_context_summary_covers_team_pipeline_delivery(client: TestClient) -> None:
+ db = Database.model_validate(client.get("/api/state").json())
+ context = build_context(db)
+ assert "Team:" in context
+ assert "Pipeline:" in context
+ assert "Delivery:" in context
+
+
+def test_briefing_returns_503_when_llm_not_configured(client: TestClient) -> None:
+ response = client.post("/api/briefing")
+ assert response.status_code == 503
+ assert "LLM_API_KEY" in response.json()["detail"]
+
+
+def test_briefing_returns_generated_text_when_configured(
+ client: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("LLM_API_KEY", "test-key")
+ monkeypatch.setenv("LLM_BASE_URL", "https://example.test/v1")
+ monkeypatch.setenv("LLM_MODEL", "test-model")
+
+ captured: dict[str, Any] = {}
+
+ def fake_handler(request: httpx.Request) -> httpx.Response:
+ captured["url"] = str(request.url)
+ captured["auth"] = request.headers.get("authorization")
+ return httpx.Response(
+ 200,
+ json={
+ "choices": [
+ {"message": {"content": "Pipeline is healthy. Watch Marcus at 116%."}}
+ ]
+ },
+ )
+
+ real_async_client = httpx.AsyncClient
+
+ def async_client_factory(*args: Any, **kwargs: Any) -> httpx.AsyncClient:
+ return real_async_client(transport=httpx.MockTransport(fake_handler))
+
+ monkeypatch.setattr("delta_command.briefing.httpx.AsyncClient", async_client_factory)
+
+ response = client.post("/api/briefing")
+ assert response.status_code == 200
+ assert "Pipeline is healthy" in response.json()["briefing"]
+ assert captured["url"] == "https://example.test/v1/chat/completions"
+ assert captured["auth"] == "Bearer test-key"
+
+
+def test_briefing_returns_502_when_provider_errors(
+ client: TestClient, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setenv("LLM_API_KEY", "test-key")
+ real_async_client = httpx.AsyncClient
+
+ def fake_handler(request: httpx.Request) -> httpx.Response:
+ return httpx.Response(500, text="upstream broken")
+
+ monkeypatch.setattr(
+ "delta_command.briefing.httpx.AsyncClient",
+ lambda *a, **k: real_async_client(transport=httpx.MockTransport(fake_handler)),
+ )
+
+ response = client.post("/api/briefing")
+ assert response.status_code == 502
+ assert "500" in response.json()["detail"]
diff --git a/apps/delta-command/backend/uv.lock b/apps/delta-command/backend/uv.lock
index d3c2e57..f037cab 100644
--- a/apps/delta-command/backend/uv.lock
+++ b/apps/delta-command/backend/uv.lock
@@ -69,13 +69,13 @@ version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
+ { name = "httpx" },
{ name = "pydantic" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
- { name = "httpx" },
{ name = "pytest" },
{ name = "ruff" },
]
@@ -83,13 +83,13 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
+ { name = "httpx", specifier = ">=0.28.0" },
{ name = "pydantic", specifier = ">=2.10.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
]
[package.metadata.requires-dev]
dev = [
- { name = "httpx", specifier = ">=0.28.0" },
{ name = "pytest", specifier = ">=8.3.0" },
{ name = "ruff", specifier = ">=0.11.0" },
]
diff --git a/apps/delta-command/src/app/dashboard/page.tsx b/apps/delta-command/src/app/dashboard/page.tsx
index ac41d58..4c84ad1 100644
--- a/apps/delta-command/src/app/dashboard/page.tsx
+++ b/apps/delta-command/src/app/dashboard/page.tsx
@@ -1,37 +1,27 @@
+import Link from "next/link";
+import { DollarSign, FolderKanban, Flame, Users, CalendarClock } from "lucide-react";
+
import { Header } from "@/components/layout/Header";
import { KPICard } from "@/components/dashboard/KPICard";
import { PipelineChart } from "@/components/dashboard/PipelineChart";
import { UtilizationChart } from "@/components/dashboard/UtilizationChart";
import { ActionItemsPanel } from "@/components/dashboard/ActionItemsPanel";
-import {
- PipelineHealthPanel,
- MilestonesPanel,
-} from "@/components/dashboard/PipelineHealthPanel";
-import {
- DeliveryOverviewPanel,
- TeamSnapshotPanel,
-} from "@/components/dashboard/DeliveryOverviewPanel";
+import { ExecutiveBriefingPanel } from "@/components/dashboard/ExecutiveBriefingPanel";
+import { MilestonesPanel } from "@/components/dashboard/MilestonesPanel";
import { ProjectCard } from "@/components/projects/ProjectCard";
import { getState } from "@/core/api";
import { buildDashboardInsights } from "@/core/dashboard-analytics";
import { formatCurrency, formatDate } from "@/core/utils";
-import {
- DollarSign,
- Target,
- FolderKanban,
- Users,
- Layers,
- Flame,
- CalendarClock,
-} from "lucide-react";
-import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
- const { engineers, opportunities, projects, lastUpdated } = await getState();
- const insights = buildDashboardInsights(engineers, opportunities, projects);
- const { pipeline, delivery, team, actions, milestones } = insights;
+ const { engineers, opportunities, projects } = await getState();
+ const { pipeline, delivery, team, actions, milestones } = buildDashboardInsights(
+ engineers,
+ opportunities,
+ projects
+ );
const activeOpps = opportunities.filter((o) => !["won", "lost"].includes(o.stage));
const pipelineValue = activeOpps.reduce((s, o) => s + o.value * (o.probability / 100), 0);
@@ -40,7 +30,6 @@ export default async function DashboardPage() {
? Math.round(engineers.reduce((s, e) => s + e.utilization, 0) / engineers.length)
: 0;
const activeProjects = projects.filter((p) => p.status === "active" || p.status === "at_risk");
- const availableCapacity = engineers.filter((e) => e.utilization < 70).length;
const atRiskProjects = projects.filter((p) => p.status === "at_risk");
const upcomingCloses = activeOpps
@@ -51,13 +40,10 @@ export default async function DashboardPage() {
return (
-
+
-
+
-
-
+
+
@@ -109,77 +83,24 @@ export default async function DashboardPage() {
-
-
-
-
-
+ {atRiskProjects.length > 0 && (
+
-
-
Upcoming Closes
-
- {formatCurrency(pipeline.closingValue30Days)} in next 30 days
-
-
-
View all
-
-
- {upcomingCloses.map((opp) => (
-
-
-
{opp.title}
-
{opp.client}
-
- {opp.stage}
-
- {opp.probability}%
-
-
-
-
-
{formatCurrency(opp.value)}
-
-
- {formatDate(opp.expectedClose)}
-
-
- Wtd {formatCurrency(opp.value * (opp.probability / 100))}
-
-
-
- ))}
-
-
-
-
-
-
-
Projects Needing Attention
-
- {atRiskProjects.length > 0
- ? `${atRiskProjects.length} at risk · ${delivery.overdueMilestones} overdue milestones`
- : "Active delivery status"}
-
-
-
View all projects
+
Projects Needing Attention
+
+ All projects
+
- {(atRiskProjects.length > 0
- ? atRiskProjects
- : projects.filter((p) => p.status === "active").slice(0, 2)
- ).map((project) => (
+ {atRiskProjects.map((project) => (
))}
-
-
+
+ )}
+
+
+ );
+}
+
+interface UpcomingClose {
+ id: string;
+ title: string;
+ client: string;
+ stage: string;
+ probability: number;
+ value: number;
+ expectedClose: string;
+}
-
+function UpcomingClosesPanel({
+ closes,
+ closingValue,
+}: {
+ closes: UpcomingClose[];
+ closingValue: number;
+}) {
+ return (
+
+
+
+
Upcoming Closes
- Last updated {formatDate(lastUpdated.split("T")[0])} · {engineers.length} engineers ·{" "}
- {activeOpps.length} active opportunities · {availableCapacity} with capacity below 70%
+ {formatCurrency(closingValue)} in next 30 days
+
+ View all
+
+
+ {closes.map((opp) => (
+
+
+
{opp.title}
+
{opp.client}
+
+ {opp.stage}
+ {opp.probability}%
+
+
+
+
{formatCurrency(opp.value)}
+
+
+ {formatDate(opp.expectedClose)}
+
+
+
+ ))}
+
);
}
diff --git a/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx b/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx
deleted file mode 100644
index f60fd01..0000000
--- a/apps/delta-command/src/components/dashboard/DeliveryOverviewPanel.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import Link from "next/link";
-import type { DeliveryInsight, TeamInsight } from "@/core/dashboard-analytics";
-import { cn, formatCurrency, utilizationVariant } from "@/core/utils";
-import { Users, Wallet, Clock, UserCheck } from "lucide-react";
-
-interface DeliveryOverviewPanelProps {
- delivery: DeliveryInsight;
-}
-
-export function DeliveryOverviewPanel({ delivery }: DeliveryOverviewPanelProps) {
- const statusRows = [
- { label: "Active", count: delivery.activeCount, color: "bg-accent" },
- { label: "Planning", count: delivery.planningCount, color: "bg-zinc-300" },
- { label: "At risk", count: delivery.atRiskCount, color: "bg-red-500" },
- ];
- const total = statusRows.reduce((s, r) => s + r.count, 0) || 1;
-
- return (
-
-
-
Delivery Overview
-
- View projects
-
-
-
-
-
-
-
- Budget burn
-
-
{delivery.burnPercent}%
-
- {formatCurrency(delivery.totalSpent)} of {formatCurrency(delivery.totalBudget)}
-
-
-
-
-
- Due soon
-
-
{delivery.milestonesDue14Days}
-
- milestones in 14 days
- {delivery.overdueMilestones > 0 &&
- ` · ${delivery.overdueMilestones} overdue`}
-
-
-
-
-
- {statusRows.map((row) =>
- row.count > 0 ? (
-
- ) : null
- )}
-
-
- {statusRows.map((row) => (
-
-
- {row.label}: {row.count}
-
- ))}
- {delivery.projectsEnding30Days > 0 && (
-
- · {delivery.projectsEnding30Days} ending in 30d
-
- )}
-
-
- );
-}
-
-interface TeamSnapshotPanelProps {
- team: TeamInsight;
- teamSize: number;
- avgUtilization: number;
-}
-
-export function TeamSnapshotPanel({
- team,
- teamSize,
- avgUtilization,
-}: TeamSnapshotPanelProps) {
- return (
-
-
-
Team Capacity
-
- View team
-
-
-
-
-
-
- {avgUtilization}%
-
-
Avg util
-
-
-
{team.benchCapacityPercent}%
-
Bench
-
-
-
{team.available.length}
-
Available
-
-
-
- {team.overallocated.length > 0 && (
-
-
Overallocated
-
- {team.overallocated.slice(0, 8).map((e) => (
-
-
-
- {e.name.split(" ")[0]}
-
- {e.utilization}%
-
- ))}
-
- {team.overallocated.length > 8 && (
-
- + {team.overallocated.length - 8} more overallocated
-
- )}
-
- )}
-
- {team.unassigned.length > 0 && (
-
-
Ready for assignment
-
- {team.unassigned.map((e) => (
-
-
- {e.name.split(" ")[0]}
-
- ))}
-
-
- )}
-
- {team.overallocated.length === 0 && team.unassigned.length === 0 && (
-
- {teamSize} engineers actively allocated across delivery workstreams.
-
- )}
-
- );
-}
diff --git a/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx b/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx
new file mode 100644
index 0000000..261a381
--- /dev/null
+++ b/apps/delta-command/src/components/dashboard/ExecutiveBriefingPanel.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState } from "react";
+import { Sparkles, RefreshCw, AlertCircle } from "lucide-react";
+import { generateBriefing } from "@/core/api";
+import { cn } from "@/core/utils";
+
+export function ExecutiveBriefingPanel() {
+ const [briefing, setBriefing] = useState
(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function handleGenerate() {
+ setLoading(true);
+ setError(null);
+ try {
+ const { briefing } = await generateBriefing();
+ setBriefing(briefing);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to generate briefing");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+ AI Executive Briefing
+
+
+ Leadership summary generated from live pipeline, delivery, and capacity data
+
+
+
+
+ {briefing ? "Regenerate" : "Generate"}
+
+
+
+ {error && (
+
+ )}
+
+ {briefing ? (
+
+ {briefing}
+
+ ) : (
+ !error &&
+ !loading && (
+
+ Click Generate to author a
+ leadership-ready summary. Requires the backend to have{" "}
+ LLM_API_KEY{" "}
+ configured (any OpenAI-compatible provider).
+
+ )
+ )}
+
+ {loading && !briefing && (
+
+
+ Composing briefing…
+
+ )}
+
+ );
+}
diff --git a/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx b/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx
new file mode 100644
index 0000000..931125d
--- /dev/null
+++ b/apps/delta-command/src/components/dashboard/MilestonesPanel.tsx
@@ -0,0 +1,58 @@
+import Link from "next/link";
+import type { MilestoneItem } from "@/core/dashboard-analytics";
+import { cn, formatShortDate } from "@/core/utils";
+
+interface MilestonesPanelProps {
+ milestones: MilestoneItem[];
+}
+
+export function MilestonesPanel({ milestones }: MilestonesPanelProps) {
+ return (
+
+
+
Upcoming Milestones
+
+ All projects
+
+
+ {milestones.length === 0 ? (
+
No open milestones
+ ) : (
+
+ {milestones.slice(0, 5).map((m) => (
+
+
+
{m.title}
+
{m.projectName}
+
+
+
+ {m.overdue
+ ? `${Math.abs(m.daysUntil)}d overdue`
+ : m.daysUntil === 0
+ ? "Today"
+ : `${m.daysUntil}d`}
+
+
+ {formatShortDate(m.dueDate)}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx b/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx
deleted file mode 100644
index 0dbca95..0000000
--- a/apps/delta-command/src/components/dashboard/PipelineHealthPanel.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import Link from "next/link";
-import type { MilestoneItem } from "@/core/dashboard-analytics";
-import type { PipelineInsight } from "@/core/dashboard-analytics";
-import { formatCurrency, formatShortDate, cn } from "@/core/utils";
-import { Handshake, FileText, CalendarClock, BarChart3 } from "lucide-react";
-
-interface PipelineHealthPanelProps {
- pipeline: PipelineInsight;
-}
-
-export function PipelineHealthPanel({ pipeline }: PipelineHealthPanelProps) {
- const stats = [
- {
- icon: FileText,
- label: "In proposal",
- value: String(pipeline.inProposal),
- detail: formatCurrency(pipeline.proposalValue),
- },
- {
- icon: Handshake,
- label: "In negotiation",
- value: String(pipeline.inNegotiation),
- detail: formatCurrency(pipeline.negotiationValue),
- },
- {
- icon: CalendarClock,
- label: "Closing in 30d",
- value: String(pipeline.closingWithin30Days),
- detail: formatCurrency(pipeline.closingValue30Days),
- },
- {
- icon: BarChart3,
- label: "Avg deal / probability",
- value: formatCurrency(pipeline.avgDealSize),
- detail: `${pipeline.avgProbability}% avg probability`,
- },
- ];
-
- return (
-
-
-
Pipeline Health
-
- View pipeline
-
-
-
- {stats.map((stat) => (
-
-
-
- {stat.label}
-
-
{stat.value}
-
{stat.detail}
-
- ))}
-
-
- );
-}
-
-interface MilestonesPanelProps {
- milestones: MilestoneItem[];
-}
-
-export function MilestonesPanel({ milestones }: MilestonesPanelProps) {
- return (
-
-
-
Upcoming Milestones
-
- All projects
-
-
- {milestones.length === 0 ? (
-
No open milestones
- ) : (
-
- {milestones.map((m) => (
-
-
-
{m.title}
-
- {m.projectName}
-
-
-
-
- {m.overdue
- ? `${Math.abs(m.daysUntil)}d overdue`
- : m.daysUntil === 0
- ? "Today"
- : `${m.daysUntil}d`}
-
-
- {formatShortDate(m.dueDate)}
-
-
-
- ))}
-
- )}
-
- );
-}
diff --git a/apps/delta-command/src/components/layout/Header.tsx b/apps/delta-command/src/components/layout/Header.tsx
index c569ea4..0e706b5 100644
--- a/apps/delta-command/src/components/layout/Header.tsx
+++ b/apps/delta-command/src/components/layout/Header.tsx
@@ -1,48 +1,25 @@
-"use client";
-
-import { Bell, Search } from "lucide-react";
import { format } from "date-fns";
interface HeaderProps {
title: string;
- subtitle?: string;
+ meta?: string;
}
-export function Header({ title, subtitle }: HeaderProps) {
+/**
+ * The header is deliberately spartan: a page title, an optional meta line
+ * (used to show a single actionable number), and today's date. Everything
+ * else lives on the page itself so the chrome never competes with data.
+ */
+export function Header({ title, meta }: HeaderProps) {
const today = format(new Date(), "MMM d, yyyy");
return (
{title}
- {subtitle &&
{subtitle}
}
-
-
-
-
- {today}
-
-
-
-
-
-
-
-
-
-
- 3
-
-
-
-
- NF
-
+ {meta &&
{meta}
}
+ {today}
);
}
diff --git a/apps/delta-command/src/components/layout/Sidebar.tsx b/apps/delta-command/src/components/layout/Sidebar.tsx
index 0e63768..0b2c3d5 100644
--- a/apps/delta-command/src/components/layout/Sidebar.tsx
+++ b/apps/delta-command/src/components/layout/Sidebar.tsx
@@ -2,13 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import {
- LayoutDashboard,
- Target,
- Users,
- FolderKanban,
- Zap,
-} from "lucide-react";
+import { LayoutDashboard, Target, Users, FolderKanban, Zap } from "lucide-react";
import { cn } from "@/core/utils";
const navigation = [
@@ -24,13 +18,10 @@ export function Sidebar() {
return (
-
+
-
-
Delta Command
-
Engineering Hub
-
+
Delta Command
@@ -54,18 +45,6 @@ export function Sidebar() {
);
})}
-
-
-
-
- Q3 Review
-
-
Leadership sync
-
- Pipeline, delivery & capacity
-
-
-
);
}
diff --git a/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx b/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx
index 1f69f64..f44f7dd 100644
--- a/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx
+++ b/apps/delta-command/src/components/opportunities/OpportunitiesPageClient.tsx
@@ -2,13 +2,13 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
+import { LayoutGrid, List, Trophy, XCircle } from "lucide-react";
+
import { Header } from "@/components/layout/Header";
import { PipelineBoard, OpportunityCard } from "@/components/opportunities/PipelineBoard";
import type { Opportunity, OpportunityStage } from "@/core/types";
-import { OPPORTUNITY_STAGES } from "@/core/types";
import { updateOpportunityStage } from "@/core/api";
import { cn, formatCurrency } from "@/core/utils";
-import { LayoutGrid, List, Trophy, XCircle } from "lucide-react";
interface OpportunitiesPageClientProps {
initialOpportunities: Opportunity[];
@@ -30,22 +30,23 @@ export function OpportunitiesPageClient({
const activeOpps = opportunities.filter((o) => !["won", "lost"].includes(o.stage));
const wonOpps = opportunities.filter((o) => o.stage === "won");
- const lostOpps = opportunities.filter((o) => o.stage === "lost");
+ const lostCount = opportunities.filter((o) => o.stage === "lost").length;
const totalValue = activeOpps.reduce((sum, o) => sum + o.value, 0);
const weightedValue = activeOpps.reduce(
(sum, o) => sum + o.value * (o.probability / 100),
0
);
+ const wonValue = wonOpps.reduce((s, o) => s + o.value, 0);
return (
-
+
Active Pipeline
{formatCurrency(totalValue)}
@@ -59,57 +60,37 @@ export function OpportunitiesPageClient({
-
Won
+
Won this cycle
+ {lostCount > 0 && (
+
+
+ {lostCount} lost
+
+ )}
- {wonOpps.length} · {formatCurrency(wonOpps.reduce((s, o) => s + o.value, 0))}
-
-
-
-
-
- {lostOpps.length}
+ {wonOpps.length} · {formatCurrency(wonValue)}
-
-
- setView("board")}
- className={cn("segmented-item", view === "board" && "segmented-item-active")}
- >
-
- Board
-
- setView("list")}
- className={cn("segmented-item", view === "list" && "segmented-item-active")}
- >
-
- List
-
-
-
-
- {OPPORTUNITY_STAGES.filter((s) => ["won", "lost"].includes(s.id)).map((stage) => {
- const count = opportunities.filter((o) => o.stage === stage.id).length;
- return (
-
-
- {stage.label}: {count}
-
- );
- })}
-
+
+ setView("board")}
+ className={cn("segmented-item", view === "board" && "segmented-item-active")}
+ >
+
+ Board
+
+ setView("list")}
+ className={cn("segmented-item", view === "list" && "segmented-item-active")}
+ >
+
+ List
+
{view === "board" ? (
diff --git a/apps/delta-command/src/components/projects/ProjectsPageClient.tsx b/apps/delta-command/src/components/projects/ProjectsPageClient.tsx
index 57e6563..d1d252e 100644
--- a/apps/delta-command/src/components/projects/ProjectsPageClient.tsx
+++ b/apps/delta-command/src/components/projects/ProjectsPageClient.tsx
@@ -2,21 +2,13 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
+import { AlertTriangle } from "lucide-react";
+
import { Header } from "@/components/layout/Header";
import { ProjectCard } from "@/components/projects/ProjectCard";
import type { Engineer, Project, ProjectStatus } from "@/core/types";
-import { PROJECT_STATUSES } from "@/core/types";
import { updateProjectStatus } from "@/core/api";
-import { cn, formatCurrency } from "@/core/utils";
-import { FolderKanban, AlertTriangle, CheckCircle2, PauseCircle } from "lucide-react";
-
-const STATUS_ICONS = {
- active: FolderKanban,
- at_risk: AlertTriangle,
- planning: PauseCircle,
- completed: CheckCircle2,
- on_hold: PauseCircle,
-} as const;
+import { formatCurrency } from "@/core/utils";
interface ProjectsPageClientProps {
initialProjects: Project[];
@@ -41,23 +33,7 @@ export function ProjectsPageClient({
const byStatus = (s: ProjectStatus) => projects.filter((p) => p.status === s);
const totalBudget = projects.reduce((sum, p) => sum + p.budget, 0);
const totalSpent = projects.reduce((sum, p) => sum + p.spent, 0);
-
- const renderSection = (title: string, items: Project[]) =>
- items.length > 0 ? (
-
-
{title}
-
- {items.map((project) => (
-
- ))}
-
-
- ) : null;
+ const burn = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
const atRisk = byStatus("at_risk");
const active = byStatus("active");
@@ -68,23 +44,17 @@ export function ProjectsPageClient({
-
+
Total Projects
{projects.length}
-
In Delivery
-
- {inDelivery}
-
-
-
-
Total Budget
+
Budget
{formatCurrency(totalBudget)}
@@ -92,53 +62,70 @@ export function ProjectsPageClient({
{formatCurrency(totalSpent)}
-
- {totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0}% of total budget
-
+
{burn}% of budget
-
- {PROJECT_STATUSES.map((status) => {
- const count = byStatus(status.id).length;
- const Icon = STATUS_ICONS[status.id] ?? FolderKanban;
- return (
-
-
-
- {status.label}
-
- {count}
-
-
- );
- })}
-
-
{atRisk.length > 0 && (
-
-
-
- At Risk
-
-
- {atRisk.map((project) => (
-
- ))}
-
-
+
}
+ titleClassName="text-error"
+ projects={atRisk}
+ engineerNames={engineerNames}
+ onStatusChange={handleStatusChange}
+ />
)}
- {renderSection("Active Delivery", active)}
- {renderSection("In Planning", planning)}
+
+
);
}
+
+function ProjectSection({
+ title,
+ titleIcon,
+ titleClassName,
+ projects,
+ engineerNames,
+ onStatusChange,
+}: {
+ title: string;
+ titleIcon?: React.ReactNode;
+ titleClassName?: string;
+ projects: Project[];
+ engineerNames: Record
;
+ onStatusChange: (id: string, status: ProjectStatus) => void;
+}) {
+ if (projects.length === 0) return null;
+ return (
+
+
+ {titleIcon}
+ {title}
+ ({projects.length})
+
+
+ {projects.map((project) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/delta-command/src/components/team/EngineerCard.tsx b/apps/delta-command/src/components/team/EngineerCard.tsx
deleted file mode 100644
index 03d7abc..0000000
--- a/apps/delta-command/src/components/team/EngineerCard.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import type { Engineer } from "@/core/types";
-import {
- cn,
- getInitials,
- getStatusBadgeColor,
- utilizationVariant,
-} from "@/core/utils";
-import { Mail, Briefcase } from "lucide-react";
-
-interface EngineerCardProps {
- engineer: Engineer;
- projectNames?: Record;
-}
-
-export function EngineerCard({ engineer, projectNames = {} }: EngineerCardProps) {
- const projects = engineer.currentProjects.map(
- (id) => projectNames[id] ?? id
- );
-
- return (
-
-
-
- {getInitials(engineer.name)}
-
-
-
{engineer.name}
-
{engineer.role}
-
-
- {engineer.email}
-
-
-
- {engineer.status}
-
-
-
-
-
- Utilization
-
- {engineer.utilization}%
-
-
-
- {engineer.utilization > 100 && (
-
- {engineer.utilization - 100}% over capacity
-
- )}
-
-
-
- {engineer.skills.map((skill) => (
-
- {skill}
-
- ))}
-
-
-
-
-
- Current Projects ({projects.length})
-
- {projects.length > 0 ? (
-
- {projects.map((name) => (
-
- {name}
-
- ))}
-
- ) : (
-
Available for assignment
- )}
-
-
- );
-}
diff --git a/apps/delta-command/src/components/team/TeamPageClient.tsx b/apps/delta-command/src/components/team/TeamPageClient.tsx
index 38e07ca..725e106 100644
--- a/apps/delta-command/src/components/team/TeamPageClient.tsx
+++ b/apps/delta-command/src/components/team/TeamPageClient.tsx
@@ -1,35 +1,24 @@
"use client";
import { useMemo, useState } from "react";
+import { AlertTriangle, CheckCircle, Clock, Users } from "lucide-react";
+
import { Header } from "@/components/layout/Header";
-import { EngineerCard } from "@/components/team/EngineerCard";
import { TeamFilters, filterEngineers } from "@/components/team/TeamFilters";
import type { TeamFilterState } from "@/components/team/TeamFilters";
import { UtilizationTimelineView } from "@/components/team/UtilizationTimeline";
import type { Engineer, Project } from "@/core/types";
-import { cn, formatNameList, utilizationVariant } from "@/core/utils";
-import {
- Users,
- AlertTriangle,
- CheckCircle,
- Clock,
- CalendarRange,
- LayoutGrid,
-} from "lucide-react";
-
-type TeamView = "timeline" | "overview";
+import { cn, formatNameList } from "@/core/utils";
interface TeamPageClientProps {
engineers: Engineer[];
projects: Project[];
}
-export function TeamPageClient({ engineers: initial, projects }: TeamPageClientProps) {
+export function TeamPageClient({ engineers: initial }: TeamPageClientProps) {
const [engineers, setEngineers] = useState(initial);
- const [view, setView] = useState("timeline");
const [filters, setFilters] = useState({ search: "", status: "all" });
- const projectNames = Object.fromEntries(projects.map((p) => [p.id, p.name]));
const filteredEngineers = useMemo(
() => filterEngineers(engineers, filters),
[engineers, filters]
@@ -40,52 +29,18 @@ export function TeamPageClient({ engineers: initial, projects }: TeamPageClientP
const avgUtil = engineers.length
? Math.round(engineers.reduce((sum, e) => sum + e.utilization, 0) / engineers.length)
: 0;
- const sortedEngineers = [...filteredEngineers].sort((a, b) => b.utilization - a.utilization);
return (
-
-
-
- setView("timeline")}
- className={cn("segmented-item", view === "timeline" && "segmented-item-active")}
- >
-
- Timeline
-
- setView("overview")}
- className={cn("segmented-item", view === "overview" && "segmented-item-active")}
- >
-
- Overview
-
-
-
- {view === "timeline"
- ? "Click any week cell to update utilization — changes save to data.json"
- : "Current-week snapshot and team member details"}
-
-
-
-
-
+
-
+
{formatNameList(overallocated.map((e) => e.name.split(" ")[0]))}
{" "}
- {overallocated.length === 1 ? "is" : "are"} overallocated this week. Adjust the timeline
- to plan redistribution.
+ {overallocated.length === 1 ? "is" : "are"} overallocated this week.
)}
- {view === "timeline" ? (
-
e.id))}
- onDatabaseChange={(db) => setEngineers(db.engineers)}
- />
- ) : (
- <>
-
-
Current Week Capacity
-
- {sortedEngineers.map((engineer) => (
-
-
- {engineer.name.split(" ")[0]}
-
-
-
-
- {engineer.utilization >= 30 && `${engineer.utilization}%`}
-
-
-
-
- {engineer.currentProjects.length} proj
-
-
- ))}
- {sortedEngineers.length === 0 && (
-
- No engineers match your filters.
-
- )}
-
-
+
-
-
Team Members
-
- {sortedEngineers.map((engineer) => (
-
- ))}
-
- {sortedEngineers.length === 0 && (
-
- No engineers match your filters.
-
- )}
-
- >
- )}
+ e.id))}
+ onDatabaseChange={(db) => setEngineers(db.engineers)}
+ />
);
diff --git a/apps/delta-command/src/core/api.ts b/apps/delta-command/src/core/api.ts
index f1626be..4538e49 100644
--- a/apps/delta-command/src/core/api.ts
+++ b/apps/delta-command/src/core/api.ts
@@ -23,7 +23,14 @@ async function apiFetch
(path: string, init?: RequestInit): Promise {
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!response.ok) {
- throw new Error(`API ${path} failed: ${response.status} ${response.statusText}`);
+ let detail = response.statusText;
+ try {
+ const body = await response.json();
+ if (typeof body?.detail === "string") detail = body.detail;
+ } catch {
+ // response wasn't JSON; keep statusText
+ }
+ throw new Error(detail);
}
return response.json() as Promise;
}
@@ -58,3 +65,8 @@ export function updateTimelineCell(
body: JSON.stringify({ engineerId, weekStart, utilization, note }),
});
}
+
+/** Ask the LLM to author an executive briefing from the current state. */
+export function generateBriefing(): Promise<{ briefing: string }> {
+ return apiFetch<{ briefing: string }>("/api/briefing", { method: "POST" });
+}
diff --git a/apps/delta-command/src/core/dashboard-analytics.ts b/apps/delta-command/src/core/dashboard-analytics.ts
index 576bf91..b584257 100644
--- a/apps/delta-command/src/core/dashboard-analytics.ts
+++ b/apps/delta-command/src/core/dashboard-analytics.ts
@@ -20,36 +20,23 @@ export interface MilestoneItem {
overdue: boolean;
}
+/** Only the fields actually rendered on the dashboard live here. */
export interface PipelineInsight {
- inProposal: number;
- proposalValue: number;
- lateStage: number;
- lateStageValue: number;
closingWithin30Days: number;
closingValue30Days: number;
- inNegotiation: number;
- negotiationValue: number;
- avgDealSize: number;
- avgProbability: number;
}
export interface DeliveryInsight {
totalBudget: number;
totalSpent: number;
burnPercent: number;
- activeCount: number;
planningCount: number;
atRiskCount: number;
- milestonesDue14Days: number;
- overdueMilestones: number;
- projectsEnding30Days: number;
}
export interface TeamInsight {
overallocated: Engineer[];
- available: Engineer[];
benchCapacityPercent: number;
- unassigned: Engineer[];
}
export interface DashboardInsights {
@@ -60,7 +47,7 @@ export interface DashboardInsights {
team: TeamInsight;
}
-function isActiveOpportunity(o: Opportunity): boolean {
+function isActive(o: Opportunity): boolean {
return !["won", "lost"].includes(o.stage);
}
@@ -69,12 +56,7 @@ export function buildDashboardInsights(
opportunities: Opportunity[],
projects: Project[]
): DashboardInsights {
- const activeOpps = opportunities.filter(isActiveOpportunity);
- const inProposal = activeOpps.filter((o) => o.stage === "proposal");
- const inNegotiation = activeOpps.filter((o) => o.stage === "negotiation");
- const lateStage = activeOpps.filter((o) =>
- ["proposal", "negotiation"].includes(o.stage)
- );
+ const activeOpps = opportunities.filter(isActive);
const closingSoon = activeOpps.filter((o) => {
const days = daysUntil(o.expectedClose);
return days >= 0 && days <= 30;
@@ -101,27 +83,21 @@ export function buildDashboardInsights(
};
})
);
-
const sortedMilestones = [...allMilestones].sort(
(a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
);
-
- const overdueMilestones = sortedMilestones.filter((m) => m.overdue);
- const milestonesDue14 = sortedMilestones.filter(
- (m) => !m.overdue && m.daysUntil <= 14
- );
+ const overdue = sortedMilestones.filter((m) => m.overdue);
+ const dueIn14 = sortedMilestones.filter((m) => !m.overdue && m.daysUntil <= 14);
const overallocated = engineers.filter((e) => e.status === "overallocated");
- const available = engineers.filter((e) => e.utilization < 70);
const unassigned = engineers.filter((e) => e.currentProjects.length === 0);
- const benchCapacity = engineers.reduce(
- (sum, e) => sum + Math.max(0, 100 - e.utilization),
- 0
- );
const benchCapacityPercent =
engineers.length > 0
- ? Math.round(benchCapacity / engineers.length)
+ ? Math.round(
+ engineers.reduce((sum, e) => sum + Math.max(0, 100 - e.utilization), 0) /
+ engineers.length
+ )
: 0;
const actions: ActionItem[] = [];
@@ -146,7 +122,7 @@ export function buildDashboardInsights(
});
}
- for (const m of overdueMilestones) {
+ for (const m of overdue) {
actions.push({
id: `ms-over-${m.id}`,
severity: "critical",
@@ -168,7 +144,7 @@ export function buildDashboardInsights(
});
}
- for (const m of milestonesDue14) {
+ for (const m of dueIn14) {
actions.push({
id: `ms-soon-${m.id}`,
severity: "warning",
@@ -178,9 +154,7 @@ export function buildDashboardInsights(
});
}
- for (const project of activeProjects.filter(
- (p) => p.spent / p.budget >= 0.9
- )) {
+ for (const project of activeProjects.filter((p) => p.spent / p.budget >= 0.9)) {
actions.push({
id: `budget-${project.id}`,
severity: "warning",
@@ -200,59 +174,23 @@ export function buildDashboardInsights(
});
}
- const severityOrder: Record = {
- critical: 0,
- warning: 1,
- info: 2,
- };
+ const severityOrder: Record = { critical: 0, warning: 1, info: 2 };
actions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
return {
actions,
milestones: sortedMilestones.slice(0, 8),
pipeline: {
- inProposal: inProposal.length,
- proposalValue: inProposal.reduce((s, o) => s + o.value, 0),
- lateStage: lateStage.length,
- lateStageValue: lateStage.reduce((s, o) => s + o.value, 0),
closingWithin30Days: closingSoon.length,
closingValue30Days: closingSoon.reduce((s, o) => s + o.value, 0),
- inNegotiation: inNegotiation.length,
- negotiationValue: inNegotiation.reduce((s, o) => s + o.value, 0),
- avgDealSize:
- activeOpps.length > 0
- ? Math.round(
- activeOpps.reduce((s, o) => s + o.value, 0) / activeOpps.length
- )
- : 0,
- avgProbability:
- activeOpps.length > 0
- ? Math.round(
- activeOpps.reduce((s, o) => s + o.probability, 0) /
- activeOpps.length
- )
- : 0,
},
delivery: {
totalBudget,
totalSpent,
- burnPercent:
- totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0,
- activeCount: projects.filter((p) => p.status === "active").length,
+ burnPercent: totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0,
planningCount: projects.filter((p) => p.status === "planning").length,
atRiskCount: projects.filter((p) => p.status === "at_risk").length,
- milestonesDue14Days: milestonesDue14.length,
- overdueMilestones: overdueMilestones.length,
- projectsEnding30Days: projects.filter((p) => {
- const d = daysUntil(p.endDate);
- return d >= 0 && d <= 30 && p.status !== "completed";
- }).length,
- },
- team: {
- overallocated,
- available,
- benchCapacityPercent,
- unassigned,
},
+ team: { overallocated, benchCapacityPercent },
};
}