diff --git a/apps/web/public/icon-192.png b/apps/web/public/icon-192.png
new file mode 100644
index 0000000..a3ae1e1
Binary files /dev/null and b/apps/web/public/icon-192.png differ
diff --git a/apps/web/public/icon-512.png b/apps/web/public/icon-512.png
new file mode 100644
index 0000000..4d234ff
Binary files /dev/null and b/apps/web/public/icon-512.png differ
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
new file mode 100644
index 0000000..858c56e
--- /dev/null
+++ b/apps/web/public/manifest.json
@@ -0,0 +1,14 @@
+{
+ "name": "Planarchy — Resource Planning",
+ "short_name": "Planarchy",
+ "description": "Resource planning and project staffing for 3D production",
+ "start_url": "/dashboard",
+ "display": "standalone",
+ "background_color": "#ffffff",
+ "theme_color": "#0284c7",
+ "orientation": "any",
+ "icons": [
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
+ ]
+}
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 0000000..e52a98b
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,128 @@
+///
+
+const CACHE_NAME = "planarchy-v1";
+const STATIC_EXTENSIONS = /\.(js|css|png|jpg|jpeg|svg|gif|ico|woff2?|ttf|eot)$/;
+
+// Offline fallback page (simple inline HTML)
+const OFFLINE_HTML = `
+
+
+
+
+ Planarchy — Offline
+
+
+
+
+
You are offline
+
Planarchy requires an internet connection. Please check your network and try again.
+
+
+
+`;
+
+// Install: pre-cache the offline fallback
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches.open(CACHE_NAME).then((cache) => {
+ return cache.put(
+ new Request("/_offline"),
+ new Response(OFFLINE_HTML, {
+ headers: { "Content-Type": "text/html; charset=utf-8" },
+ })
+ );
+ })
+ );
+ // Activate immediately
+ self.skipWaiting();
+});
+
+// Activate: clean up old caches
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches.keys().then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key !== CACHE_NAME)
+ .map((key) => caches.delete(key))
+ )
+ )
+ );
+ // Take control of all clients immediately
+ self.clients.claim();
+});
+
+// Fetch: strategy depends on request type
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+ const url = new URL(request.url);
+
+ // Skip non-GET requests
+ if (request.method !== "GET") return;
+
+ // Skip chrome-extension, ws, etc.
+ if (!url.protocol.startsWith("http")) return;
+
+ // API calls and tRPC: network-first
+ if (url.pathname.startsWith("/api/")) {
+ event.respondWith(
+ fetch(request).catch(() => {
+ return new Response(
+ JSON.stringify({ error: "offline" }),
+ {
+ status: 503,
+ headers: { "Content-Type": "application/json" },
+ }
+ );
+ })
+ );
+ return;
+ }
+
+ // Static assets: cache-first
+ if (STATIC_EXTENSIONS.test(url.pathname) || url.pathname.startsWith("/_next/static/")) {
+ event.respondWith(
+ caches.match(request).then((cached) => {
+ if (cached) return cached;
+ return fetch(request).then((response) => {
+ // Only cache successful responses
+ if (response.ok) {
+ const clone = response.clone();
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
+ }
+ return response;
+ });
+ })
+ );
+ return;
+ }
+
+ // Navigation requests: network-first with offline fallback
+ if (request.mode === "navigate") {
+ event.respondWith(
+ fetch(request).catch(() => caches.match("/_offline"))
+ );
+ return;
+ }
+
+ // Everything else: network-first, silent fail
+ event.respondWith(
+ fetch(request).catch(() => caches.match(request))
+ );
+});
diff --git a/apps/web/src/app/(app)/admin/webhooks/page.tsx b/apps/web/src/app/(app)/admin/webhooks/page.tsx
new file mode 100644
index 0000000..48961c1
--- /dev/null
+++ b/apps/web/src/app/(app)/admin/webhooks/page.tsx
@@ -0,0 +1,5 @@
+import { WebhooksClient } from "~/components/admin/WebhooksClient.js";
+
+export default function AdminWebhooksPage() {
+ return ;
+}
diff --git a/apps/web/src/app/(app)/analytics/insights/page.tsx b/apps/web/src/app/(app)/analytics/insights/page.tsx
new file mode 100644
index 0000000..afa268b
--- /dev/null
+++ b/apps/web/src/app/(app)/analytics/insights/page.tsx
@@ -0,0 +1,17 @@
+import { InsightsPanel } from "~/components/analytics/InsightsPanel.js";
+
+export default function InsightsPage() {
+ return (
+
+
+
+ AI Insights
+
+
+ Anomaly detection and AI-generated project narratives
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/api/perf/route.ts b/apps/web/src/app/api/perf/route.ts
new file mode 100644
index 0000000..2e5771d
--- /dev/null
+++ b/apps/web/src/app/api/perf/route.ts
@@ -0,0 +1,67 @@
+import { NextResponse } from "next/server";
+import { eventBus } from "@planarchy/api/sse";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+
+/**
+ * GET /api/perf — Runtime performance metrics.
+ *
+ * Protected by CRON_SECRET header or query param.
+ * Returns Node.js memory usage, process uptime, and SSE connection count.
+ */
+export function GET(request: Request) {
+ const cronSecret = process.env["CRON_SECRET"];
+
+ if (cronSecret) {
+ const url = new URL(request.url);
+ const headerToken = request.headers.get("authorization")?.replace("Bearer ", "");
+ const queryToken = url.searchParams.get("token");
+
+ if (headerToken !== cronSecret && queryToken !== cronSecret) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ }
+
+ const mem = process.memoryUsage();
+
+ return NextResponse.json({
+ timestamp: new Date().toISOString(),
+ uptime: {
+ seconds: Math.round(process.uptime()),
+ formatted: formatUptime(process.uptime()),
+ },
+ memory: {
+ heapUsedMB: round(mem.heapUsed / 1024 / 1024),
+ heapTotalMB: round(mem.heapTotal / 1024 / 1024),
+ rssMB: round(mem.rss / 1024 / 1024),
+ externalMB: round(mem.external / 1024 / 1024),
+ arrayBuffersMB: round(mem.arrayBuffers / 1024 / 1024),
+ },
+ sse: {
+ activeConnections: eventBus.subscriberCount,
+ },
+ node: {
+ version: process.version,
+ platform: process.platform,
+ arch: process.arch,
+ },
+ });
+}
+
+function round(n: number): number {
+ return Math.round(n * 100) / 100;
+}
+
+function formatUptime(seconds: number): string {
+ const d = Math.floor(seconds / 86400);
+ const h = Math.floor((seconds % 86400) / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = Math.floor(seconds % 60);
+ const parts: string[] = [];
+ if (d > 0) parts.push(`${d}d`);
+ if (h > 0) parts.push(`${h}h`);
+ if (m > 0) parts.push(`${m}m`);
+ parts.push(`${s}s`);
+ return parts.join(" ");
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index d14eb92..f1cff42 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,6 +1,8 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import { Manrope, Source_Sans_3 } from "next/font/google";
import { TRPCProvider } from "~/lib/trpc/provider.js";
+import { ServiceWorkerRegistration } from "~/components/layout/ServiceWorkerRegistration.js";
+import { InstallPrompt } from "~/components/layout/InstallPrompt.js";
import "./globals.css";
const uiFont = Source_Sans_3({
@@ -19,6 +21,12 @@ export const metadata: Metadata = {
metadataBase: new URL("https://planarchy.hartmut-noerenberg.com"),
title: "plANARCHY — Resource Planning",
description: "Interactive resource planning and project staffing tool",
+ manifest: "/manifest.json",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "default",
+ title: "Planarchy",
+ },
openGraph: {
title: "plANARCHY — Resource Planning",
description: "Estimates, staffing, chargeability, and timelines in one workspace.",
@@ -33,6 +41,10 @@ export const metadata: Metadata = {
},
};
+export const viewport: Viewport = {
+ themeColor: "#0284c7",
+};
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
@@ -47,6 +59,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
+
+