feat: Sprint 5 — AI insights, webhooks/Slack, PWA, performance monitoring

AI-Powered Insights (G9):
- Rule-based anomaly detection: budget burn rate, staffing gaps, utilization,
  timeline overruns across all active projects
- AI narrative generation via existing Azure OpenAI integration
- Cached in project dynamicFields to avoid regeneration
- New /analytics/insights page with anomaly feed + project summaries
- Sidebar nav: "AI Insights" under Analytics

Webhook System + Slack (G10):
- Webhook model in Prisma (url, secret, events, isActive)
- HMAC-SHA256 signed payloads with 5s timeout fire-and-forget dispatch
- Slack-aware: routes hooks.slack.com URLs through Slack formatter
- 6 events integrated: allocation.created/updated/deleted, project.created/
  status_changed, vacation.approved
- Admin UI: /admin/webhooks with CRUD, test button, event checkboxes
- webhook router: list, getById, create, update, delete, test

PWA Support (G11):
- manifest.json with standalone display, brand-colored icons (192+512px)
- Service worker: cache-first for static, network-first for API, offline fallback
- ServiceWorkerRegistration component with 60-min update checks
- InstallPrompt banner with 30-day dismissal memory
- Apple Web App meta tags + viewport theme color

Performance Monitoring (A15):
- Pino structured logging (JSON prod, pretty dev) via LOG_LEVEL env
- tRPC logging middleware on all protectedProcedure calls
- Request ID (UUID) per call for log correlation
- Slow query warnings (>500ms) at warn level
- GET /api/perf endpoint: memory, uptime, SSE connections, node version

Fix: renamed scenario.apply to scenario.applyScenario (tRPC reserved word)

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-20 06:57:20 +01:00
parent e1368c7ef7
commit fbeab5cd79
30 changed files with 2228 additions and 5 deletions
@@ -67,6 +67,9 @@ function ReportBuilderIcon() {
function GraphIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
}
function InsightsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /></svg>;
}
function NotificationsIcon() {
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
}
@@ -146,6 +149,7 @@ const navSections: NavSection[] = [
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/reports/builder", label: "Report Builder", icon: <ReportBuilderIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
{ href: "/analytics/insights", label: "AI Insights", icon: <InsightsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
],
},
{
@@ -184,6 +188,7 @@ const adminNavEntries: AdminEntry[] = [
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
{ href: "/admin/webhooks", label: "Webhooks", icon: <AdminIcon /> },
];
/**
@@ -0,0 +1,102 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const DISMISS_KEY = "planarchy_pwa_dismiss";
const DISMISS_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export function InstallPrompt() {
const [visible, setVisible] = useState(false);
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
// Check if dismissed recently
try {
const dismissed = localStorage.getItem(DISMISS_KEY);
if (dismissed) {
const timestamp = parseInt(dismissed, 10);
if (Date.now() - timestamp < DISMISS_DURATION_MS) return;
}
} catch {
// localStorage unavailable
}
// Check if already installed (standalone mode)
if (window.matchMedia("(display-mode: standalone)").matches) return;
const handler = (e: Event) => {
e.preventDefault();
deferredPromptRef.current = e as BeforeInstallPromptEvent;
setVisible(true);
};
window.addEventListener("beforeinstallprompt", handler);
return () => window.removeEventListener("beforeinstallprompt", handler);
}, []);
const handleInstall = useCallback(async () => {
const prompt = deferredPromptRef.current;
if (!prompt) return;
await prompt.prompt();
const { outcome } = await prompt.userChoice;
if (outcome === "accepted") {
setVisible(false);
}
deferredPromptRef.current = null;
}, []);
const handleDismiss = useCallback(() => {
setVisible(false);
deferredPromptRef.current = null;
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// ignore
}
}, []);
if (!visible) return null;
return (
<div className="fixed bottom-20 left-4 right-4 z-50 mx-auto max-w-md animate-in slide-in-from-bottom-4 duration-300 sm:left-auto sm:right-6">
<div className="flex items-center gap-3 rounded-2xl border border-brand-200/60 bg-white/95 px-4 py-3 shadow-lg backdrop-blur-xl dark:border-brand-900/40 dark:bg-slate-900/95">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-600 text-white shadow-md shadow-brand-600/25">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-gray-50">
Install Planarchy
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Add to home screen for quick access
</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={handleDismiss}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-800"
>
Later
</button>
<button
type="button"
onClick={() => void handleInstall()}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-brand-700"
>
Install
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import { useEffect } from "react";
export function ServiceWorkerRegistration() {
useEffect(() => {
if (
typeof window === "undefined" ||
!("serviceWorker" in navigator) ||
process.env.NODE_ENV === "development"
) {
return;
}
navigator.serviceWorker
.register("/sw.js")
.then((registration) => {
// Check for updates every 60 minutes
setInterval(() => {
registration.update().catch(() => {
// Silent fail on update check
});
}, 60 * 60 * 1000);
})
.catch(() => {
// Service worker registration failed — non-critical
});
}, []);
return null;
}