update the orders page ui

This commit is contained in:
2026-08-18 13:41:34 +05:30
parent 01faf3cd8d
commit 573618cad8
39 changed files with 6285 additions and 1737 deletions

12
.claude/launch.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "tower",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}

View File

@@ -8,8 +8,7 @@ import {
DollarSign, Award, HelpCircle, BarChart3, Activity, Menu, MessageSquare,
Package, TrendingUp, Clipboard, LogOut, Shield, MapPin, Bell, CloudOff, Megaphone,
RefreshCw, User, Search, ShoppingCart, Home, Settings as SettingsIcon, MoreVertical,
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck,
ChevronDown, ChevronRight
Building2, Sparkles, CheckSquare, UserCheck, Store, GraduationCap, ArrowLeft, FolderOpen, Brain, Zap, Network, Cpu, Command, BadgeCheck
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -34,6 +33,7 @@ import RoleSwitcher from "./components/dev/RoleSwitcher";
import NotificationPanel from "./components/notifications/NotificationPanel";
import { NotificationEngine } from "./components/notifications/NotificationEngine";
import { Toaster } from "./components/ui/toaster";
import SidebarNav, { NavigationMenu, useSidebarCollapsed } from "./components/navigation/SidebarNav";
import OwliverProvider from "./components/owliver/OwliverProvider";
import OwliverShell from "./components/owliver/OwliverShell";
@@ -381,91 +381,13 @@ const secondaryItemsFor = (userRole) =>
.flatMap((group) => group.items)
.filter((item) => !EXISTING_MENU_URLS.has(item.url));
/**
* Category-first sidebar: the top level is the work areas, and a category's
* pages appear only once it is open. One category is open at a time, so the
* sidebar stays about as tall as its category list however many pages a role
* has.
*/
function NavigationMenu({ location, userRole, closeSheet }) {
const categories = primaryCategoriesFor(userRole);
const currentPath = location.pathname;
const activeCategory =
categories.find((group) => group.items.some((item) => item.url === currentPath))
?.category || null;
const [openCategory, setOpenCategory] = React.useState(activeCategory);
// Navigating re-opens whichever category owns the new page, so the current
// page is never hidden behind a closed category. Between navigations the
// user's own choice wins — this only re-syncs when the route actually
// changes. (React's documented "adjust state when a prop changes" pattern:
// it re-renders immediately rather than painting a stale frame.)
const syncedPath = React.useRef(currentPath);
if (syncedPath.current !== currentPath) {
syncedPath.current = currentPath;
setOpenCategory(activeCategory);
}
return (
<nav className="space-y-1">
{categories.map((categoryGroup) => {
const isOpen = openCategory === categoryGroup.category;
const holdsActivePage = activeCategory === categoryGroup.category;
return (
<div key={categoryGroup.category}>
<button
type="button"
onClick={() => setOpenCategory(isOpen ? null : categoryGroup.category)}
aria-expanded={isOpen}
className={`w-full flex items-center justify-between gap-2 px-4 py-2 text-xs sm:text-sm font-bold uppercase tracking-wider transition-colors focus-visible:outline-none ${
holdsActivePage
? 'text-[#0039A6]'
: 'text-slate-600 hover:text-[#0039A6]'
}`}
>
<span>{categoryGroup.category}</span>
{isOpen
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />}
</button>
{isOpen && (
<div className="space-y-0.5 mt-0.5">
{categoryGroup.items.map((item) => {
const isActive = currentPath === item.url;
return (
<Link
key={item.title}
to={item.url}
onClick={closeSheet}
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-all duration-200 ${
isActive
? 'bg-[#0039A6] text-white shadow-md font-medium'
: 'text-slate-600 hover:bg-[#C8DBDC]/30 hover:text-[#3C4F5C]'
}`}
>
<item.icon className="w-4 h-4" />
<span className="text-sm">{item.title}</span>
</Link>
);
})}
</div>
)}
</div>
);
})}
</nav>
);
}
function LayoutChrome({ children }) {
const location = useLocation();
const navigate = useNavigate();
const [showNotifications, setShowNotifications] = React.useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
// Remembered across navigation and reloads; see useSidebarCollapsed.
const [sidebarCollapsed, toggleSidebar] = useSidebarCollapsed();
const { data: user } = useQuery({
queryKey: ['current-user-layout'],
@@ -494,6 +416,7 @@ function LayoutChrome({ children }) {
const userRole = localStorage.getItem("dev_role_override") || user?.user_role || user?.role || "admin";
// Categories that left the sidebar now live in the "More options" menu.
const secondaryNavItems = secondaryItemsFor(userRole);
const navCategories = primaryCategoriesFor(userRole);
const userName = user?.full_name || user?.email || "User";
const userInitial = userName.charAt(0).toUpperCase();
@@ -591,7 +514,11 @@ function LayoutChrome({ children }) {
</div>
</div>
<div className="p-3">
<NavigationMenu location={location} userRole={userRole} closeSheet={() => setMobileMenuOpen(false)} />
<NavigationMenu
categories={navCategories}
currentPath={location.pathname}
onNavigate={() => setMobileMenuOpen(false)}
/>
</div>
<div className="p-3 border-t border-slate-200">
<Button variant="ghost" className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50" onClick={() => {handleLogout(); setMobileMenuOpen(false);}}>
@@ -719,12 +646,15 @@ function LayoutChrome({ children }) {
<div className="krow-shell-body flex flex-1 min-h-0 overflow-hidden min-w-0">
{/* The rail itself never moves: it is a track in a row that is exactly
viewport-minus-header tall. Only the navigation inside it scrolls, and
only when the nav is taller than that. */}
<aside className="hidden lg:flex lg:flex-col w-64 flex-shrink-0 min-h-0 bg-white border-r border-slate-200 shadow-sm">
<div className="flex-1 min-h-0 overflow-y-auto p-3">
<NavigationMenu location={location} userRole={userRole} closeSheet={() => {}} />
</div>
</aside>
only when the nav is taller than that. Collapsing changes the track's
width, so <main> and the Owliver panel re-flow rather than being
covered. */}
<SidebarNav
categories={navCategories}
currentPath={location.pathname}
collapsed={sidebarCollapsed}
onToggle={toggleSidebar}
/>
{/* The page's scrollport — the one place vertical scrolling happens.
`min-w-0`/`min-h-0` are both required: without them a wide table or a

View File

@@ -0,0 +1,373 @@
import React from "react";
import { Link } from "react-router-dom";
import {
BarChart3, Briefcase, Building2, Calendar, ChevronRight,
ChevronsLeft, ChevronsRight, Clipboard, DollarSign, GraduationCap, HelpCircle,
LayoutDashboard, Layers, Network, Shield, Store, TrendingUp, UserCheck, Users,
} from "lucide-react";
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from "@/components/ui/tooltip";
/**
* KROW sidebar navigation
* -----------------------
* The same navigation in two widths. Expanded, it is category headers with
* their pages underneath; collapsed, it is the same hierarchy reduced to icons —
* the open category still shows its pages, the closed ones show one icon each,
* and a tooltip carries whatever the label no longer can.
*
* expanded (256px) collapsed (72px)
* ┌────────────────────┐ ┌────────┐
* │ OVERVIEW ˅ │ │ ⌘ │ ← open category's pages
* │ ⌘ KROW Command │ │ ▦ │
* │ ▦ Home │ │ ─── │
* │ OPERATIONS › │ │ ▤ │ ← closed categories
* └────────────────────┘ └────────┘
*
* Nothing about the routes changes: `roleNavigationMap` in Layout.jsx is still
* the single source of what a role can reach, and this component only decides
* how that list is presented. The open category always follows the route, so a
* page is never hidden behind a collapsed section.
*/
/* ------------------------------------------------------------------ icons */
/**
* One icon per work area, from the same lucide family the page links already
* use. A category with no entry falls back to `Layers` rather than borrowing a
* meaning it doesn't have.
*/
const CATEGORY_ICONS = {
Overview: LayoutDashboard,
Operations: Briefcase,
Management: Building2,
Workforce: Users,
Finance: DollarSign,
Analytics: BarChart3,
Network: Network,
Compliance: Shield,
Orders: Clipboard,
Marketplace: Store,
Team: UserCheck,
Business: Briefcase,
Work: Calendar,
Growth: TrendingUp,
Support: HelpCircle,
Onboarding: GraduationCap,
};
const categoryIcon = (name) => CATEGORY_ICONS[name] || Layers;
/* ----------------------------------------------------------- route match */
/**
* `createPageUrl()` lower-cases the paths it builds ("/events") while App.jsx
* declares them capitalised ("/Events"); React Router matches either, so both
* spellings reach the same page depending on whether the user clicked a link or
* typed/refreshed the URL. Comparing the raw strings therefore left a deep-linked
* page with no highlighted item and no open section. Normalise both sides.
*/
const samePath = (a, b) =>
String(a || "").toLowerCase().replace(/\/+$/, "") ===
String(b || "").toLowerCase().replace(/\/+$/, "");
/* ----------------------------------------------------------- persistence */
const STORAGE_KEY = "krow.sidebar.collapsed";
/** Below this the rail starts collapsed: the page and Owliver need the width. */
const AUTO_COLLAPSE_BELOW = 1280;
/**
* Collapsed/expanded, remembered across navigation and reloads.
* A stored choice always wins; with no choice yet, the viewport decides once.
*/
export function useSidebarCollapsed() {
const [collapsed, setCollapsed] = React.useState(() => {
if (typeof window === "undefined") return false;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "1") return true;
if (stored === "0") return false;
} catch {
/* private mode — fall through to the viewport default */
}
return window.innerWidth < AUTO_COLLAPSE_BELOW;
});
const toggle = React.useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
window.localStorage.setItem(STORAGE_KEY, next ? "1" : "0");
} catch {
/* the choice still holds for this session */
}
return next;
});
}, []);
return [collapsed, toggle];
}
/* ------------------------------------------------------------- primitives */
const FOCUS =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/40 focus-visible:ring-offset-1 focus-visible:ring-offset-white";
/** A tooltip that only exists while the rail is collapsed. */
function Hint({ label, show, children }) {
if (!show) return children;
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={10}
className="border border-slate-700/40 bg-[#1C323E] px-2.5 py-1.5 text-[12px] font-medium text-white shadow-lg"
>
{label}
</TooltipContent>
</Tooltip>
);
}
/** One page link, in either width. */
function NavItem({ item, isActive, collapsed, onNavigate }) {
const Icon = item.icon;
return (
<Hint label={item.title} show={collapsed}>
<Link
to={item.url}
onClick={onNavigate}
aria-current={isActive ? "page" : undefined}
aria-label={collapsed ? item.title : undefined}
title={undefined}
className={`relative flex items-center rounded-lg transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
collapsed ? "h-9 w-9 justify-center" : "gap-2.5 px-2.5 py-[7px]"
} ${
isActive
? "bg-[#0039A6]/[0.07] font-semibold text-[#1C323E]"
: "font-medium text-slate-600 hover:bg-slate-100/80 hover:text-[#1C323E]"
}`}
>
{/* The active marker: a short rule on the rail's edge, not a filled block. */}
{isActive && (
<span
aria-hidden="true"
className={`absolute rounded-full bg-[#0039A6] ${
collapsed ? "-left-[9px] top-1.5 h-6 w-[3px]" : "-left-2 top-1.5 bottom-1.5 w-[3px]"
}`}
/>
)}
<Icon
className={`h-4 w-4 flex-shrink-0 ${isActive ? "text-[#0039A6]" : "text-slate-400"}`}
/>
{!collapsed && <span className="truncate text-[13.5px] leading-tight">{item.title}</span>}
</Link>
</Hint>
);
}
/* ------------------------------------------------------------------- menu */
/**
* The navigation itself. Used at both widths and inside the mobile drawer,
* where it always renders expanded.
*/
export function NavigationMenu({ categories, currentPath, collapsed = false, onNavigate }) {
const activeCategory =
categories.find((group) => group.items.some((item) => samePath(item.url, currentPath)))
?.category || null;
const [openCategory, setOpenCategory] = React.useState(activeCategory);
// Navigating re-opens whichever category owns the new page, so the current
// page is never hidden behind a closed category. Between navigations the
// user's own choice wins — this only re-syncs when the route actually
// changes. (React's documented "adjust state when a prop changes" pattern:
// it re-renders immediately rather than painting a stale frame.)
const syncedPath = React.useRef(currentPath);
if (syncedPath.current !== currentPath) {
syncedPath.current = currentPath;
setOpenCategory(activeCategory);
}
return (
<nav aria-label="Main" className={collapsed ? "space-y-1" : "space-y-0.5"}>
{categories.map((group) => {
const isOpen = openCategory === group.category;
const holdsActivePage = activeCategory === group.category;
const CategoryIcon = categoryIcon(group.category);
const sectionId = `nav-section-${group.category.replace(/\s+/g, "-").toLowerCase()}`;
const toggle = () => setOpenCategory(isOpen ? null : group.category);
/* --- collapsed: an open category becomes its pages, a closed one
stays a single icon, so the hierarchy survives the width. --- */
if (collapsed) {
return (
<div key={group.category} className="flex flex-col items-center gap-1">
{isOpen ? (
<>
<span aria-hidden="true" className="mt-1 h-px w-6 flex-shrink-0 bg-slate-200" />
{/* The open category keeps its own icon at this width too, so
the icons below it are visibly *its* pages and there is
still something to click to close it. */}
<Hint label={group.category} show>
<button
type="button"
onClick={toggle}
aria-expanded
aria-controls={sectionId}
aria-label={`${group.category} navigation`}
className={`flex h-7 w-9 items-center justify-center rounded-lg text-[#0039A6] transition-colors duration-150 motion-reduce:transition-none hover:bg-slate-100/80 ${FOCUS}`}
>
<CategoryIcon className="h-[15px] w-[15px]" />
</button>
</Hint>
<div
id={sectionId}
role="group"
aria-label={group.category}
className="flex flex-col items-center gap-1"
>
{group.items.map((item) => (
<NavItem
key={item.title}
item={item}
isActive={samePath(item.url, currentPath)}
collapsed
onNavigate={onNavigate}
/>
))}
</div>
<span aria-hidden="true" className="mb-1 h-px w-6 flex-shrink-0 bg-slate-200" />
</>
) : (
<Hint label={group.category} show>
<button
type="button"
onClick={toggle}
aria-expanded={false}
aria-controls={sectionId}
aria-label={`${group.category} navigation`}
className={`relative flex h-9 w-9 items-center justify-center rounded-lg transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
holdsActivePage
? "bg-[#0039A6]/[0.07] text-[#0039A6]"
: "text-slate-500 hover:bg-slate-100/80 hover:text-[#1C323E]"
}`}
>
<CategoryIcon className="h-[18px] w-[18px]" />
{holdsActivePage && (
<span
aria-hidden="true"
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-[#0039A6]"
/>
)}
</button>
</Hint>
)}
</div>
);
}
/* --- expanded --- */
return (
<div key={group.category}>
<button
type="button"
onClick={toggle}
aria-expanded={isOpen}
aria-controls={sectionId}
className={`flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors duration-150 motion-reduce:transition-none ${FOCUS} ${
holdsActivePage
? "text-[#0039A6]"
: "text-slate-500 hover:bg-slate-100/60 hover:text-[#1C323E]"
}`}
>
<CategoryIcon
className={`h-4 w-4 flex-shrink-0 ${holdsActivePage ? "text-[#0039A6]" : "text-slate-400"}`}
/>
<span className="min-w-0 flex-1 truncate text-[11px] font-bold uppercase tracking-[0.08em]">
{group.category}
</span>
<ChevronRight
aria-hidden="true"
className={`h-3.5 w-3.5 flex-shrink-0 text-slate-400 transition-transform duration-200 motion-reduce:transition-none ${
isOpen ? "rotate-90" : ""
}`}
/>
</button>
{isOpen && (
<div id={sectionId} role="group" aria-label={group.category} className="mb-1 ml-[19px] mt-0.5 space-y-0.5 border-l border-slate-200 pl-2">
{group.items.map((item) => (
<NavItem
key={item.title}
item={item}
isActive={samePath(item.url, currentPath)}
collapsed={false}
onNavigate={onNavigate}
/>
))}
</div>
)}
</div>
);
})}
</nav>
);
}
/* ------------------------------------------------------------------ rail */
/**
* The desktop rail: a track in the shell's body row that changes width rather
* than overlaying the page, so <main> and the Owliver panel simply re-flow.
*/
export default function SidebarNav({ categories, currentPath, collapsed, onToggle }) {
const ChevronIcon = collapsed ? ChevronsRight : ChevronsLeft;
return (
<TooltipProvider delayDuration={120} skipDelayDuration={300}>
<aside
data-collapsed={collapsed ? "true" : "false"}
className={`hidden min-h-0 flex-shrink-0 flex-col border-r border-slate-200 bg-white shadow-sm transition-[width] duration-200 ease-out motion-reduce:transition-none lg:flex ${
collapsed ? "w-[72px]" : "w-64"
}`}
>
<div className={`flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-3 ${collapsed ? "px-3" : "px-3"}`}>
<NavigationMenu
categories={categories}
currentPath={currentPath}
collapsed={collapsed}
/>
</div>
{/* The collapse control lives in the rail, at the end of the navigation
it controls — not floating over the page. */}
<div className="flex-shrink-0 border-t border-slate-200 p-2">
<Hint label="Expand sidebar" show={collapsed}>
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-pressed={collapsed}
className={`flex items-center rounded-lg text-slate-500 transition-colors duration-150 motion-reduce:transition-none hover:bg-slate-100/80 hover:text-[#1C323E] ${FOCUS} ${
collapsed ? "mx-auto h-9 w-9 justify-center" : "w-full gap-2.5 px-2.5 py-2"
}`}
>
<ChevronIcon className="h-4 w-4 flex-shrink-0" />
{!collapsed && (
<span className="truncate text-[12.5px] font-medium">Collapse sidebar</span>
)}
</button>
</Hint>
</div>
</aside>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,174 @@
import { AlertTriangle, CheckCircle2, Clock, FileText, Users, X, Zap } from "lucide-react";
/**
* Order presentation primitives — KROW Control Tower
* --------------------------------------------------
* One source of truth for how an order *looks* wherever it appears: the Order
* Management table and Owliver's operational cards both render from here, so a
* status pill or a staffing meter can never drift between the page and the
* copilot reading that same page.
*
* Presentation only. Nothing here computes business state — status strings and
* headcounts arrive already calculated by the page (Events.jsx) or by the
* Owliver page registry, which mirror each other deliberately.
*/
/* -------------------------------------------------------------- status pill */
/**
* Compact status tokens. Colour carries meaning and nothing else: the pill is a
* quiet tinted chip with a solid dot, not a saturated block, so a table of nine
* orders does not read as a paint chart.
*/
export const STATUS_TOKENS = {
"Draft": { label: "Draft", dot: "bg-slate-400", text: "text-slate-600", bg: "bg-slate-50", border: "border-slate-200", icon: FileText },
"Pending": { label: "Pending", dot: "bg-amber-500", text: "text-amber-800", bg: "bg-amber-50", border: "border-amber-200", icon: Clock },
"Partial Staffed": { label: "Partial", dot: "bg-orange-500", text: "text-orange-800", bg: "bg-orange-50", border: "border-orange-200", icon: AlertTriangle },
"Fully Staffed": { label: "Fully staffed", dot: "bg-emerald-500", text: "text-emerald-800", bg: "bg-emerald-50", border: "border-emerald-200", icon: CheckCircle2 },
"Active": { label: "Active", dot: "bg-[#0039A6]", text: "text-[#0039A6]", bg: "bg-blue-50", border: "border-blue-200", icon: Users },
"Completed": { label: "Completed", dot: "bg-slate-300", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", icon: CheckCircle2 },
"Canceled": { label: "Canceled", dot: "bg-red-500", text: "text-red-700", bg: "bg-red-50", border: "border-red-200", icon: X },
};
const RAPID_TOKEN = {
label: "Rapid",
dot: "bg-red-500",
text: "text-red-700",
bg: "bg-red-50",
border: "border-red-200",
icon: Zap,
};
const FALLBACK_TOKEN = STATUS_TOKENS["Draft"];
export const statusToken = (status, isRapid) =>
isRapid ? RAPID_TOKEN : STATUS_TOKENS[status] || { ...FALLBACK_TOKEN, label: status || "Unknown" };
/**
* The status of one order, as a compact pill.
* `hasConflicts` adds a small warning marker rather than a second badge — a
* conflict is an annotation on the status, not a status of its own.
*/
export function OrderStatusPill({ status, isRapid = false, hasConflicts = false, className = "" }) {
const token = statusToken(status, isRapid);
return (
<span className={`inline-flex items-center gap-1.5 ${className}`}>
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] text-[11px] font-semibold leading-none whitespace-nowrap ${token.bg} ${token.border} ${token.text}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${token.dot}`} />
{token.label}
</span>
{hasConflicts && (
<AlertTriangle className="h-3.5 w-3.5 text-orange-500" aria-label="Scheduling conflict" />
)}
</span>
);
}
/* ----------------------------------------------------------------- staffing */
/**
* How urgent an order's staffing gap is.
* full — at requested headcount
* high — under half filled, or nobody assigned at all
* medium — filled, but short
* none — nothing requested yet (a draft with no roles priced in)
*/
export function severityOf(assigned, requested) {
const a = Number(assigned) || 0;
const r = Number(requested) || 0;
if (r <= 0) return "none";
if (a >= r) return "full";
return a / r < 0.5 ? "high" : "medium";
}
export const SEVERITY_TOKENS = {
high: { label: "High", fill: "bg-orange-500", track: "bg-orange-100", text: "text-orange-700", bg: "bg-orange-50", border: "border-orange-200", rail: "bg-orange-500" },
medium: { label: "Medium", fill: "bg-amber-500", track: "bg-amber-100", text: "text-amber-700", bg: "bg-amber-50", border: "border-amber-200", rail: "bg-amber-400" },
full: { label: "On track", fill: "bg-emerald-500", track: "bg-emerald-100", text: "text-emerald-700", bg: "bg-emerald-50", border: "border-emerald-200", rail: "bg-transparent" },
none: { label: "Not sized", fill: "bg-slate-300", track: "bg-slate-100", text: "text-slate-500", bg: "bg-slate-50", border: "border-slate-200", rail: "bg-transparent" },
};
export const fillPercent = (assigned, requested) => {
const a = Number(assigned) || 0;
const r = Number(requested) || 0;
if (r <= 0) return 0;
return Math.min(100, Math.round((a / r) * 100));
};
const SEGMENTS = 10;
/**
* `4 / 9` · `44%` · ▮▮▮▮▯▯▯▯▯▯
*
* The ratio is the fact, the percentage is the read, and the segmented track is
* the glance. Segments rather than a smooth bar: ten discrete blocks are
* countable at a glance and land on the same visual rhythm in a dense table row
* as they do in a copilot card.
*/
export function StaffingMeter({
assigned,
requested,
size = "md",
align = "start",
showFigures = true,
className = "",
}) {
const a = Number(assigned) || 0;
const r = Number(requested) || 0;
const pct = fillPercent(a, r);
const severity = severityOf(a, r);
const token = SEVERITY_TOKENS[severity];
const litSegments = r > 0 ? Math.max(a > 0 ? 1 : 0, Math.round((pct / 100) * SEGMENTS)) : 0;
const figures = size === "sm" ? "text-[11px]" : "text-[12.5px]";
const trackH = size === "sm" ? "h-[3px]" : "h-1";
return (
<div className={`min-w-0 ${align === "center" ? "text-center" : ""} ${className}`}>
{showFigures && (
<div
className={`flex items-baseline gap-1.5 ${figures} ${align === "center" ? "justify-center" : ""}`}
>
<span className="font-semibold tabular-nums text-slate-900">
{a} <span className="font-normal text-slate-400">/</span> {r || "—"}
</span>
<span className={`text-[11px] font-medium tabular-nums ${severity === "full" ? "text-emerald-600" : "text-slate-500"}`}>
{r > 0 ? `${pct}%` : ""}
</span>
</div>
)}
<div
className={`mt-1.5 flex gap-[2px] ${align === "center" ? "justify-center" : ""}`}
role="img"
aria-label={r > 0 ? `${a} of ${r} staffed, ${pct} percent` : "No headcount requested"}
>
{Array.from({ length: SEGMENTS }).map((_, i) => (
<span
key={i}
className={`${trackH} flex-1 rounded-[1px] transition-colors duration-200 ${
i < litSegments ? token.fill : "bg-slate-200/70"
}`}
/>
))}
</div>
</div>
);
}
/* ------------------------------------------------------------------- dates */
/** "Aug 17" / "6:00 PM – 11:30 PM" formatting shared by the table and Owliver. */
export const to12Hour = (time24) => {
if (!time24) return null;
const [rawH, m] = String(time24).split(":");
const h = Number(rawH);
if (Number.isNaN(h)) return time24;
const suffix = h >= 12 ? "PM" : "AM";
return `${h % 12 || 12}:${m} ${suffix}`;
};
export default OrderStatusPill;

View File

@@ -1,369 +0,0 @@
import ReactMarkdown from "react-markdown";
import { useNavigate } from "react-router-dom";
import {
ArrowRight,
Check,
Loader2,
X,
Clock,
MapPin,
Sparkles,
Building2,
AlertTriangle,
} from "lucide-react";
import { stripContext } from "@/lib/owliver/context";
import { extractActions, isValidRoute } from "@/lib/owliver/actions";
/** Compact, non-technical progress line for an agent tool call. */
function ToolLine({ toolCall }) {
const status = toolCall.status || "pending";
const running = ["pending", "running", "in_progress"].includes(status);
const failed = ["failed", "error"].includes(status);
const label = toolCall.display_projection?.label || toolCall.name || "Working";
const activeLabel = toolCall.display_projection?.active_label || `${label}…`;
const errorLabel = toolCall.display_projection?.error_label || `${label} failed`;
return (
<div className="flex items-center gap-2 text-[12px] py-0.5" style={{ color: "var(--ow-ink-subtle)" }}>
{running ? <Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
: failed ? <X className="w-3 h-3 flex-shrink-0" style={{ color: "var(--ow-danger)" }} />
: <Check className="w-3 h-3 flex-shrink-0" />}
<span className="truncate">{running ? activeLabel : failed ? errorLabel : label}</span>
</div>
);
}
function extractNodeText(node) {
if (!node) return "";
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(extractNodeText).join("");
if (node.props?.children) return extractNodeText(node.props.children);
return "";
}
function parseListItemText(text) {
const rawClean = String(text || "").trim();
let id = "";
const idMatch = rawClean.match(/\[id:([^\]]+)\]/);
if (idMatch) {
id = idMatch[1];
}
const clean = rawClean.replace(/\[id:[^\]]+\]/g, "").trim();
// Ratio check e.g. "3/10 staffed" or "9/20 staffed" or "3/10"
const ratioMatch = clean.match(/(\d+)\/(\d+)\s*(staffed|filled|seats)?/i);
if (ratioMatch) {
const assigned = parseInt(ratioMatch[1], 10);
const requested = parseInt(ratioMatch[2], 10);
const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0;
let titlePart = clean.replace(/·|\b\d+\/\d+\s*(staffed|filled|seats)?/gi, "").trim();
let hub = "";
let dateTime = "";
const hubMatch = titlePart.match(/\(([^)]+)\)/);
if (hubMatch) {
hub = hubMatch[1];
titlePart = titlePart.replace(/\([^)]+\)/, "").trim();
}
const commaParts = titlePart.split(",");
if (commaParts.length > 1) {
titlePart = commaParts[0].trim();
dateTime = commaParts.slice(1).join(",").trim();
}
let company = titlePart;
let eventName = "";
if (titlePart.includes("—")) {
const parts = titlePart.split("—");
company = parts[0].trim();
eventName = parts.slice(1).join("—").trim();
} else if (titlePart.includes(" - ")) {
const parts = titlePart.split(" - ");
company = parts[0].trim();
eventName = parts.slice(1).join(" - ").trim();
}
return {
type: "order_card",
id,
company,
eventName,
hub,
dateTime,
assigned,
requested,
pct,
raw: clean,
};
}
// Step flow check e.g. "Step 1: ...", "1. ...", "First: ..."
const stepMatch = clean.match(/^(step\s*\d+|\d+[.)]|first|second|third|fourth|fifth)[\s:]*(.*)/i);
if (stepMatch) {
return {
type: "flow_step",
stepLabel: stepMatch[1],
content: stepMatch[2] || clean,
raw: clean,
};
}
return {
type: "generic_card",
raw: clean,
};
}
export default function OwliverAnswer({ message }) {
const navigate = useNavigate();
const content = stripContext(message.content);
if (message.role === "user") {
if (!content) return null;
return (
<p className="text-[13.5px] leading-[1.55] whitespace-pre-wrap m-0">
{content}
</p>
);
}
const actions = extractActions(content);
const toolCalls = message.tool_calls || [];
return (
<>
{toolCalls.length > 0 && (
<div className="mb-2 space-y-0.5">
{toolCalls.map((tc, i) => <ToolLine key={i} toolCall={tc} />)}
</div>
)}
{content && (
<div className="ow-prose text-[13.5px] min-w-0">
<ReactMarkdown
components={{
ul: ({ children }) => <div className="my-3 space-y-2.5 not-prose">{children}</div>,
ol: ({ children }) => <div className="my-3 space-y-2.5 not-prose">{children}</div>,
li: ({ children }) => {
const rawText = extractNodeText(children);
const parsed = parseListItemText(rawText);
if (parsed.type === "order_card") {
const isFull = parsed.pct >= 100;
const isLow = parsed.pct < 50;
const borderAccent = isFull
? "border-l-emerald-500"
: isLow
? "border-l-amber-500"
: "border-l-[#0039A6]";
const badgeStyle = isFull
? "bg-emerald-50 text-emerald-700 border-emerald-200"
: isLow
? "bg-amber-50 text-amber-700 border-amber-200"
: "bg-blue-50 text-blue-700 border-blue-200";
const progressColor = isFull
? "bg-emerald-500"
: isLow
? "bg-amber-500"
: "bg-[#0039A6]";
return (
<div className={`p-3.5 rounded-xl bg-white border border-slate-200/90 shadow-2xs hover:shadow-md transition-all space-y-2.5 border-l-4 ${borderAccent}`}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="text-xs font-extrabold text-slate-900 truncate flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-[#0039A6] shrink-0" />
<span className="truncate">{parsed.company}</span>
</h4>
{parsed.eventName && (
<p className="text-[11px] font-semibold text-slate-600 truncate mt-0.5">{parsed.eventName}</p>
)}
{parsed.hub && (
<p className="text-[10px] text-slate-500 flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3 text-slate-400 shrink-0" />
<span className="truncate">{parsed.hub}</span>
</p>
)}
</div>
<span className={`px-2 py-0.5 rounded-full border text-[10px] font-bold shrink-0 flex items-center gap-1.5 ${badgeStyle}`}>
<span className={`w-1.5 h-1.5 rounded-full ${progressColor} animate-pulse`} />
{parsed.assigned}/{parsed.requested} Staffed
</span>
</div>
{/* Progress Bar */}
<div className="space-y-1 pt-0.5">
<div className="flex justify-between text-[10px] font-bold text-slate-500">
<span>Staffing Fill</span>
<span className="text-slate-700">{parsed.pct}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all duration-500 ${progressColor}`} style={{ width: `${parsed.pct}%` }} />
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-slate-500 pt-1.5 border-t border-slate-100">
<span className="flex items-center gap-1 font-medium truncate">
<Clock className="w-3.5 h-3.5 text-blue-500 shrink-0" />
<span className="truncate">{parsed.dateTime || "Scheduled Shift"}</span>
</span>
<button
type="button"
onClick={() => {
if (parsed.id) {
navigate(`/EventDetail?id=${parsed.id}`);
} else {
navigate("/Events");
}
}}
className="text-[#0039A6] font-bold flex items-center gap-1 hover:underline text-[11px] shrink-0 cursor-pointer"
>
<span>View Order</span>
<ArrowRight className="w-3 h-3" />
</button>
</div>
</div>
);
}
if (parsed.type === "flow_step") {
return (
<div className="flex items-start gap-3 p-3 rounded-xl bg-gradient-to-r from-blue-50/70 to-white border border-blue-100/90 shadow-2xs">
<div className="w-6 h-6 rounded-full bg-[#0039A6] text-white font-black text-[10px] flex items-center justify-center shrink-0 shadow-xs uppercase">
{parsed.stepLabel.slice(0, 3)}
</div>
<div className="flex-1 min-w-0 pt-0.5">
<p className="text-xs font-semibold text-slate-800 leading-relaxed">{parsed.content}</p>
</div>
</div>
);
}
return (
<div className="p-3 rounded-xl bg-white border border-slate-200/90 shadow-2xs hover:border-blue-300 transition-all flex items-start gap-2.5">
<div className="w-2 h-2 rounded-full bg-[#0039A6] mt-1.5 shrink-0" />
<div className="flex-1 text-xs text-slate-800 font-medium leading-normal">
{children}
</div>
</div>
);
},
p: ({ children }) => {
const text = extractNodeText(children);
// If paragraph mentions order risk, headcount ratio, or open seats
const ratioMatch = text.match(/(\d+)\/(\d+)/);
if (ratioMatch) {
const assigned = parseInt(ratioMatch[1], 10);
const requested = parseInt(ratioMatch[2], 10);
const pct = requested > 0 ? Math.min(100, Math.round((assigned / requested) * 100)) : 0;
return (
<div className="my-2.5 p-3.5 rounded-xl bg-white border border-amber-200 shadow-2xs border-l-4 border-l-amber-500 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-50 text-amber-800 border border-amber-200 text-[10px] font-extrabold uppercase tracking-wide">
<AlertTriangle className="w-3 h-3 text-amber-600" /> Order Attention
</span>
<p className="text-xs font-bold text-slate-900 mt-1.5 leading-snug">{text}</p>
</div>
<span className="px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 font-extrabold text-[11px] shrink-0">
{assigned}/{requested}
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-bold text-slate-500">
<span>Staffing Fill</span>
<span className="text-slate-700">{pct}%</span>
</div>
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div className="h-full bg-amber-500 rounded-full transition-all duration-500" style={{ width: `${pct}%` }} />
</div>
</div>
<div className="flex items-center justify-end pt-1 border-t border-slate-100">
<button
type="button"
onClick={() => navigate("/Events")}
className="text-[#0039A6] font-bold flex items-center gap-1 hover:underline text-[11px]"
>
<span>View Orders</span>
<ArrowRight className="w-3 h-3" />
</button>
</div>
</div>
);
}
// If paragraph is a summary metric callout
if (/(\d+)\s*(orders|shifts|positions|candidates|seats)/i.test(text)) {
return (
<div className="p-3 rounded-xl bg-gradient-to-r from-blue-50 to-indigo-50/60 border border-blue-200/70 text-[#0039A6] text-xs font-bold flex items-center gap-2 my-2 shadow-2xs">
<Sparkles className="w-4 h-4 text-blue-600 shrink-0" />
<span>{children}</span>
</div>
);
}
return <p className="mb-2 text-xs leading-relaxed text-slate-800">{children}</p>;
},
a: ({ href, children }) =>
isValidRoute(href) ? (
<a
href={href}
onClick={(e) => { e.preventDefault(); navigate(href); }}
className="underline underline-offset-2 font-medium"
style={{ color: "var(--ow-brand)" }}
>
{children}
</a>
) : (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
style={{ color: "var(--ow-brand)" }}
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
)}
{actions.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{actions.map((action) => (
<button
key={action.route}
type="button"
onClick={() => navigate(action.route)}
className="inline-flex items-center gap-1.5 max-w-full rounded-full border px-3 py-1.5 text-[12.5px] font-medium transition-colors hover:bg-black/[0.03] focus-visible:outline-none focus-visible:ring-2"
style={{
borderColor: "var(--ow-line-strong)",
background: "var(--ow-surface-raised)",
color: "var(--ow-ink)",
outlineColor: "var(--ow-brand)",
}}
>
<span className="truncate">{action.label}</span>
<ArrowRight className="w-3.5 h-3.5 flex-shrink-0" style={{ color: "var(--ow-brand)" }} />
</button>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ArrowUp, AlertCircle, Loader2, X } from "lucide-react";
/**
* The composer.
*
* ┌──────────────────────────────────────┐
* │ Ask Owliver about Saving Engine... │
* │ │
* │ ✦ Saving Engine [ ↑ ] │
* └──────────────────────────────────────┘
*
* One field that starts at a single line and grows to about four before it
* scrolls, a context chip that names what the question will be answered
* against, and a send affordance that only lights up when there is something to
* send. Enter sends, Shift+Enter breaks the line, and the whole thing is docked
* — it never scrolls away with the conversation.
*
* While a turn is in flight the field stays mounted and readable but refuses
* input, so a follow-up is never asked against half an answer.
*/
export default function OwliverComposer({
onSubmit, placeholder, contextLabel, disabled, error, onRetry, autoFocus,
}) {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
const inputRef = useRef(null);
// Grow with the content, then scroll — a composer that swallows the panel is
// worse than one that scrolls.
useEffect(() => {
const el = inputRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 104)}px`;
}, [value]);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
// The field is re-armed the moment Owliver finishes, so the next question can
// be typed without reaching for the mouse.
useEffect(() => {
if (!disabled) inputRef.current?.focus({ preventScroll: true });
}, [disabled]);
const submit = useCallback(() => {
const text = value.trim();
if (!text || disabled) return;
setValue("");
onSubmit(text);
}, [value, disabled, onSubmit]);
const onKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const canSend = value.trim().length > 0 && !disabled;
return (
<div className="flex-shrink-0 border-t border-slate-200 bg-white px-3 pb-3 pt-2.5">
{error && (
<div className="mb-2 flex items-start gap-2 rounded-lg border border-red-200 bg-red-50/70 px-2.5 py-2">
<AlertCircle className="mt-[1px] h-3.5 w-3.5 flex-shrink-0 text-red-500" />
<p className="min-w-0 flex-1 text-[11.5px] leading-snug text-red-800">{error}</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="flex-shrink-0 text-[11.5px] font-semibold text-red-700 underline underline-offset-2"
>
Try again
</button>
)}
</div>
)}
<form
onSubmit={(e) => { e.preventDefault(); submit(); }}
className={`rounded-[14px] border bg-white transition-all duration-150 ${
focused
? "border-[#0039A6]/45 shadow-[0_0_0_3px_rgba(0,57,166,0.10)]"
: "border-slate-200 hover:border-slate-300"
}`}
>
<div className="px-3 pt-2.5">
<textarea
ref={inputRef}
rows={1}
value={value}
disabled={disabled}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder={placeholder || "Ask Owliver anything..."}
aria-label="Ask Owliver"
className="block max-h-[104px] w-full resize-none border-0 bg-transparent p-0 text-[13px] leading-[1.5] text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-0 disabled:opacity-60"
/>
</div>
<div className="flex items-center gap-2 px-2.5 pb-2 pt-2">
{/* What the answer will be computed against. */}
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-slate-50 px-1.5 py-[3px] text-[10.5px] font-medium text-slate-500">
<span aria-hidden="true" className="text-[#0039A6]">✦</span>
<span className="truncate">{contextLabel || "Current page"}</span>
</span>
{value && !disabled && (
<button
type="button"
onClick={() => { setValue(""); inputRef.current?.focus(); }}
aria-label="Clear"
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<X className="h-3 w-3" />
</button>
)}
<span className="ml-auto hidden flex-shrink-0 text-[10px] text-slate-300 sm:block">
{disabled ? "Working…" : "↵ send"}
</span>
<button
type="submit"
disabled={!canSend}
aria-label="Send"
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg transition-all duration-150 ${
canSend
? "bg-[#0039A6] text-white hover:bg-[#002d85]"
: "bg-slate-100 text-slate-300"
}`}
>
{disabled ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useState } from "react";
import { ChevronDown, Database } from "lucide-react";
/**
* What Owliver is answering from.
*
* ⛁ Using Saving Engine data ⌄
* $285K saved · 27 opportunities · $8K at risk
*
* The claim on the first line is what makes the panel legible as page-bound;
* the facts underneath are what make it credible, and they are read from the
* page's own signals, so the bar can never state something the page does not.
* It is expandable rather than always-open because context is reassurance, not
* content — it should cost one line until someone asks.
*/
export default function OwliverContextBar({ label, facts = [], loading }) {
const [open, setOpen] = useState(false);
if (!label) return null;
const expandable = facts.length > 0;
return (
<div className="flex-shrink-0 border-b border-slate-200 bg-slate-50/80">
<button
type="button"
onClick={expandable ? () => setOpen((v) => !v) : undefined}
disabled={!expandable}
aria-expanded={expandable ? open : undefined}
className={`flex w-full items-center gap-1.5 px-3.5 py-1.5 text-left ${
expandable ? "transition-colors hover:bg-slate-100/70" : "cursor-default"
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[#0039A6]/30`}
>
<Database className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold text-slate-600">
{loading ? "Reading this page…" : label}
</span>
{expandable && (
<ChevronDown
className={`h-3 w-3 flex-shrink-0 text-slate-400 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
/>
)}
</button>
{open && facts.length > 0 && (
<p className="ow-fade-in flex flex-wrap items-center gap-x-1.5 gap-y-0.5 px-3.5 pb-1.5 text-[11px] leading-tight">
{facts.map((fact, i) => (
<span key={`${fact.label}-${i}`} className="inline-flex items-center gap-1.5">
{i > 0 && <span className="text-slate-300">·</span>}
<span className={`font-semibold tabular-nums ${fact.tone === "risk" ? "text-orange-600" : "text-slate-900"}`}>
{fact.value}
</span>
<span className="text-slate-500">{fact.label}</span>
</span>
))}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import OwliverSuggestions from "./OwliverSuggestions";
/**
* The pack marks the figure inside a read line with `**…**`. Splitting on it is
* cheaper — and safer — than reaching for a markdown renderer for one emphasis.
*/
function Emphasised({ text }) {
return text.split(/\*\*(.+?)\*\*/g).map((part, i) =>
i % 2 === 1 ? (
<span key={i} className="font-semibold text-slate-900">{part}</span>
) : (
<span key={i}>{part}</span>
)
);
}
/**
* The panel before anything has been asked.
*
* Not a greeting and not a dashboard: the read on the current workspace — which
* the page's own data produced — followed by the questions that read makes worth
* asking. Two short blocks and nothing else, because the conversation is the
* surface and this is only its starting line.
*/
export default function OwliverEmptyState({
read, loading, failed, suggestions, onSelect, disabled, workspaceTitle,
}) {
if (loading) {
return (
<div className="space-y-2 px-0.5 pt-1" aria-live="polite">
<div className="h-3 w-[45%] animate-pulse rounded-full bg-slate-200" />
<div className="h-3.5 w-[85%] animate-pulse rounded-full bg-slate-200" />
<div className="h-3 w-[95%] animate-pulse rounded-full bg-slate-100" />
<span className="sr-only">Owliver is reading this page&rsquo;s data</span>
</div>
);
}
const toneRail = {
positive: "border-emerald-300",
risk: "border-orange-300",
neutral: "border-[#0039A6]/40",
};
return (
<div className="space-y-4">
{failed ? (
<p className="text-[13px] leading-relaxed text-slate-600">
I couldn&rsquo;t read this page&rsquo;s data just now — ask me anything and I&rsquo;ll pull what I need.
</p>
) : (
read && (
<section className={`border-l-2 pl-3 ${toneRail[read.tone] || toneRail.neutral}`}>
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
Current read
</p>
<p className="mt-1.5 text-[14px] font-semibold leading-snug text-slate-900">
{read.headline}
</p>
{(read.lines || []).map((line, i) => (
<p key={i} className="mt-1.5 text-[12.5px] leading-relaxed text-slate-500">
<Emphasised text={line} />
</p>
))}
{read.stat && (
<p className="mt-2.5 flex items-baseline gap-1.5 border-t border-slate-100 pt-2">
<span className="text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
{read.stat.label}
</span>
<span className="text-[13px] font-bold tabular-nums text-slate-900">{read.stat.value}</span>
{read.stat.trend && (
<span className="text-[11.5px] font-semibold text-emerald-700">{read.stat.trend}</span>
)}
</p>
)}
</section>
)
)}
<OwliverSuggestions
heading={`Explore ${workspaceTitle}`}
suggestions={suggestions}
onSelect={onSelect}
disabled={disabled}
limit={5}
/>
</div>
);
}

View File

@@ -1,107 +1,75 @@
import { ArrowLeft, Maximize2, Minimize2, PanelRightClose, SquarePen } from "lucide-react";
import OwliverAvatar from "./OwliverAvatar";
import { Maximize2, Minimize2, MoreHorizontal, SquarePen, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Image 1 Header Style:
* [ owl ] Owliver ← ✎ ↗ 🚪
* Positions
* Identity, and the page Owliver is bound to. Nothing else.
*
* Every control here does what its label says. The back arrow returns to the
* prompt surface and only appears when a thread exists to leave; the pencil
* starts a fresh conversation. (It previously rendered a History icon wired to
* `onNewConversation`, which silently discarded the thread, and the back
* control was never rendered at all — so a reply was a dead end.)
* ✦ Owliver ⋯ ×
* Saving Engine
*
* Two lines carry the only two facts a header needs here: who this is, and what
* it is currently reading. Every secondary control collapses into one overflow
* menu, so the header keeps a single visual weight and the panel's attention
* stays on the conversation below it.
*/
export default function OwliverHeader({
subtitle,
onNewConversation,
onShowInsight,
onToggleExpanded,
workspace,
onClose,
onNewConversation,
onToggleExpanded,
isExpanded,
canGoBack,
hasThread,
}) {
const iconBtn =
"w-8 h-8 rounded-full flex items-center justify-center transition-colors " +
"hover:bg-black/[0.05] focus-visible:outline-none focus-visible:ring-2 " +
"disabled:opacity-30 disabled:cursor-not-allowed";
"flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors duration-150 " +
"hover:bg-slate-100 hover:text-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30";
return (
<header
className="flex items-center gap-3 flex-shrink-0 px-4 pt-3 pb-2.5"
style={{ borderColor: "var(--ow-line)" }}
>
<OwliverAvatar size={32} rounded="rounded-full" />
<header className="flex flex-shrink-0 items-center gap-2.5 border-b border-slate-200 bg-white px-3.5 py-2.5">
<span
aria-hidden="true"
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md bg-[#0039A6] text-[13px] font-semibold leading-none text-white"
>
✦
</span>
<div className="min-w-0 flex-1">
<p
className="text-[15px] font-bold leading-tight truncate"
style={{ color: "var(--ow-ink)" }}
>
<p className="truncate text-[13.5px] font-bold leading-tight tracking-[-0.01em] text-slate-900">
Owliver
</p>
<p
className="text-[12.5px] font-medium leading-tight truncate"
style={{ color: "var(--ow-ink-muted)" }}
>
{subtitle}
<p className="truncate text-[11.5px] font-medium leading-tight text-slate-500">
{workspace}
</p>
</div>
<div className="flex items-center gap-1 flex-shrink-0" style={{ color: "var(--ow-ink-muted)" }}>
{canGoBack && (
<button
type="button"
onClick={onShowInsight}
aria-label="Back to suggestions"
title="Back"
className={iconBtn}
style={{ outlineColor: "var(--ow-brand)" }}
>
<ArrowLeft className="w-[17px] h-[17px]" />
</button>
)}
<div className="flex flex-shrink-0 items-center gap-0.5">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" aria-label="Owliver options" title="Options" className={iconBtn}>
<MoreHorizontal className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onClick={onNewConversation} disabled={!hasThread}>
<SquarePen className="mr-2 h-4 w-4" />
New conversation
</DropdownMenuItem>
<DropdownMenuItem onClick={onToggleExpanded} className="hidden lg:flex">
{isExpanded ? <Minimize2 className="mr-2 h-4 w-4" /> : <Maximize2 className="mr-2 h-4 w-4" />}
{isExpanded ? "Collapse panel" : "Expand panel"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{hasThread && (
<button
type="button"
onClick={onNewConversation}
aria-label="Start a new conversation"
title="New conversation"
className={iconBtn}
style={{ outlineColor: "var(--ow-brand)" }}
>
<SquarePen className="w-[17px] h-[17px]" />
</button>
)}
<button
type="button"
onClick={onToggleExpanded}
aria-label={isExpanded ? "Collapse Owliver" : "Expand Owliver"}
aria-pressed={isExpanded}
title={isExpanded ? "Collapse" : "Expand"}
className={`${iconBtn} hidden lg:flex`}
style={{ outlineColor: "var(--ow-brand)" }}
>
{isExpanded
? <Minimize2 className="w-[17px] h-[17px]" />
: <Maximize2 className="w-[17px] h-[17px]" />}
</button>
<button
type="button"
onClick={onClose}
aria-label="Close Owliver"
title="Close"
className={iconBtn}
style={{ outlineColor: "var(--ow-brand)" }}
>
<PanelRightClose className="w-[18px] h-[18px]" />
<button type="button" onClick={onClose} aria-label="Close Owliver" title="Close" className={iconBtn}>
<X className="h-4 w-4" />
</button>
</div>
</header>
);
}

View File

@@ -1,58 +0,0 @@
/**
* The read on the current page, shown above the suggestions:
*
* 2 shifts are short of requested headcount this week.
* Reviewing this week's schedule — coverage, labor cost and
* upcoming staffing needs.
* ─────────────────────────────────────────────────────
*
* The headline is the finding; the supporting line says what Owliver is reading
* to get there, which is what makes the panel feel page-aware before you ask it
* anything. Both come from the page's own data via buildInsight() — nothing here
* is ever invented, and there is no greeting: the old "Good afternoon, Alex"
* block cost ~120px before the user reached anything actionable, and its
* placeholder copy was about hiring, on pages that have nothing to do with it.
*
* No insight is a valid state: the panel then opens straight onto its
* suggestions.
*/
export default function OwliverInsight({ insight, loading, failed }) {
if (loading) {
return (
<div className="space-y-2" aria-live="polite">
<div className="h-4 w-[80%] rounded-full animate-pulse bg-neutral-200" />
<div className="h-3 w-[95%] rounded-full animate-pulse bg-neutral-200" />
<span className="sr-only">Owliver is reading this page&rsquo;s data</span>
</div>
);
}
const line = failed
? "I couldn't read this page's data just now — ask me anything and I'll pull what I need."
: insight?.headline;
const supporting = failed ? null : insight?.supporting;
if (!line) return null;
// No padding and no trailing rule: this sits inside an Astryx ChatMessage,
// which owns the gutter and the separation from the next turn.
return (
<div>
<p
className="text-[13.5px] font-medium leading-snug m-0"
style={{ color: "var(--ow-ink)" }}
>
{line}
</p>
{supporting && (
<p
className="text-[12.5px] leading-relaxed mt-1.5 m-0"
style={{ color: "var(--ow-ink-muted)" }}
>
{supporting}
</p>
)}
</div>
);
}

View File

@@ -5,29 +5,34 @@ import { useLocation, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { krow } from "@/api/client";
import {
resolveDomain, pageNameFromPath, detailEntityFor, rankSuggestions,
resolveDomain, pageNameFromPath, detailEntityFor,
OWLIVER_EXCLUDED, PAGE_CONTEXTS,
} from "@/lib/owliver/pageRegistry";
import { buildPageContext } from "@/lib/owliver/context";
import getKnowledge from "@/lib/owliver/knowledge";
import { getOwliverAnswer, lastTopicOf } from "@/lib/owliver/responseEngine";
/**
* OwliverProvider
* ---------------
* Owns the Owliver conversation and the current page context. Mounted once at
* the authenticated layout level, so the assistant survives navigation.
* Owns the conversation and the page context.
*
* Performance contract: mounting this provider performs **no** entity reads.
* Page signals load lazily the first time the user opens Owliver, and are then
* cached per page by TanStack Query.
* Active page → domain → knowledge pack → read / suggestions / answers
*
* There is exactly **one** submission path — `send()`. The prompt box and the
* suggestion chips both call it; a suggestion is a pre-written prompt, never a
* separate request shape and never a canned answer.
* The page is the source of context. The pack resolved for the active page
* decides what Owliver knows, what it offers to answer, and how it answers —
* so nothing on this surface is shared between pages, and a page that has no
* pack still falls back to its own registry entry rather than to a generic one.
*
* Answers come from the on-device engine in lib/owliver/owliverEngine.js, which
* reasons over `pageContext` — the same normalised page data the panel already
* has. Nothing is sent anywhere: there is no conversation on a server, no socket
* and no poll, so a turn is a local async call that always settles.
* There is exactly one submission path — `send()`. Suggestion chips, follow-up
* chips, in-answer action buttons and the composer all call it; a suggestion is
* a pre-written prompt, never a canned answer.
*
* A turn is staged rather than streamed: the answer is composed locally, then
* its thinking lines are played out and its blocks revealed one at a time. That
* is deliberate — when a real streaming transport replaces the local pack, the
* same three states (thinking → partial → complete) are already what the
* surface renders.
*/
const OwliverContext = createContext(null);
@@ -38,45 +43,44 @@ export const useOwliver = () => {
return ctx;
};
/** Pacing for the staged turn. Long enough to read, short enough not to wait. */
const STAGE_MS = 620;
const BLOCK_MS = 260;
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function OwliverProvider({ children }) {
const location = useLocation();
const [searchParams] = useSearchParams();
// Owliver opens with the page. It is a non-modal card beside the work, not a
// dialog the user has to go and find, so there is no closed default state to
// discover — `close()` and the launcher pill handle dismiss/reopen.
const [isOpen, setIsOpen] = useState(true);
// Docked beside the work on a desktop, a bottom sheet on a phone — where it
// would bury the page it is about, so it starts closed there.
const [isOpen, setIsOpen] = useState(
() => typeof window === "undefined" || window.matchMedia("(min-width: 768px)").matches
);
const [isExpanded, setIsExpanded] = useState(false);
// Owliver is prompt-first: which surface is showing is explicit state, never
// inferred from `messages.length`. "prompt" is greeting → insight →
// suggestions → input; only a deliberate user action moves to
// "conversation".
const [viewMode, setViewMode] = useState("prompt");
// The panel is open from the first paint, so its page signals are wanted from
// the first paint too.
const [hasOpened, setHasOpened] = useState(true);
const [messages, setMessages] = useState([]);
const [error, setError] = useState(null);
// `isSending` means "a turn is in flight", from the moment the user submits
// until the agent has finished answering. The response surface decides on its
// own when to stop showing the working line (as soon as real text lands), so
// this can stay true through streaming without pinning a spinner on screen.
const [isSending, setIsSendingState] = useState(false);
const [thinkingStage, setThinkingStage] = useState(null);
const [followUps, setFollowUps] = useState([]);
const isSendingRef = useRef(false);
const setSending = useCallback((next) => {
isSendingRef.current = next;
setIsSendingState(next);
}, []);
// Every turn takes a ticket. A turn whose ticket has been superseded — by a
// navigation, a reset, or a newer question — stops writing to state instead of
// finishing into a conversation that has moved on.
const turnRef = useRef(0);
const lastSentRef = useRef(null);
// Mirrors `messages` so failure handling can read the current thread without
// taking a dependency on it (and re-arming the timeout on every token).
const messagesRef = useRef(messages);
messagesRef.current = messages;
// A page may publish its own live data (including tab/view state and the
// selected record) via useOwliverPageContext.
/* ------------------------------------------------------- page context */
const [pageRegistration, setPageRegistration] = useState({ key: null, data: null });
const registerPage = useCallback((key, data) => {
setPageRegistration((prev) =>
@@ -88,13 +92,13 @@ export function OwliverProvider({ children }) {
const isExcluded = OWLIVER_EXCLUDED.has(pageName);
const detailEntity = detailEntityFor(pageName);
// A registered page context wins over the route-derived domain — it knows
// which in-page view is active.
// A page that registers its own context wins over the route-derived domain —
// it knows which in-page view is active.
const registered = pageRegistration.key ? PAGE_CONTEXTS[pageRegistration.key] : null;
const domain = useMemo(
() => registered || resolveDomain(pageName),
[registered, pageName]
);
const domain = useMemo(() => registered || resolveDomain(pageName), [registered, pageName]);
/** The knowledge pack for this page. Everything below reads from it. */
const knowledge = useMemo(() => getKnowledge(domain.key, domain), [domain]);
const { data: user } = useQuery({
queryKey: ["current-user-layout"], // shared with Layout — no extra request
@@ -106,9 +110,9 @@ export function OwliverProvider({ children }) {
(typeof localStorage !== "undefined" && localStorage.getItem("dev_role_override")) ||
user?.user_role || user?.role || "admin";
/* ---------------------------------------------------------- page signals */
// Owliver prefers data the page already loaded. It only fetches its own —
// and only after the user has opened it once — when no page has registered.
/* ---------------------------------------------------------- page data */
// Owliver prefers data the page already loaded, and only fetches its own when
// no page has registered.
const usePageData = !!registered;
const {
@@ -118,13 +122,12 @@ export function OwliverProvider({ children }) {
} = useQuery({
queryKey: ["owliver-signals", domain.key],
queryFn: () => domain.loadSignals(),
enabled: hasOpened && !isExcluded && !usePageData && !!domain.loadSignals,
enabled: !isExcluded && !usePageData && !!domain.loadSignals,
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 1,
});
// `signals` is the normalised summary the registry reasons about.
const signals = useMemo(() => {
// `|| {}` matters: destructuring defaults cover `undefined` but throw on `null`.
if (usePageData) return domain.summarise(pageRegistration.data || {});
@@ -134,104 +137,103 @@ export function OwliverProvider({ children }) {
const signalsLoading = usePageData ? false : fetchLoading;
const signalsError = usePageData ? false : fetchError;
const insight = useMemo(
() => (signals ? domain.buildInsight(signals) : null),
[signals, domain]
/** The dynamic current read — a different shape of finding on every page. */
const read = useMemo(
() => (signals ? knowledge.read(signals) : null),
[signals, knowledge]
);
/** The questions this page's data makes worth asking. */
const suggestions = useMemo(
() => (signals ? rankSuggestions(domain.buildSuggestions(signals)) : []),
[signals, domain]
() => (signals ? (knowledge.suggestions(signals) || []).slice(0, 5) : []),
[signals, knowledge]
);
const pageContext = useMemo(
() => buildPageContext({
pageName, domain, role, searchParams, signals, insight, detailEntity,
pageName, domain, role, searchParams, signals,
insight: read ? { headline: read.headline, supporting: read.lines?.[0] } : null,
detailEntity,
}),
[pageName, domain, role, searchParams, signals, insight, detailEntity]
[pageName, domain, role, searchParams, signals, read, detailEntity]
);
// A request that never lands must not leave the surface looking broken.
// Roll the optimistic turn back off the thread, return to the prompt-first
// surface, and let the compact inline error carry the message — the insight
// and suggestions stay available underneath it.
const failSend = useCallback((err) => {
if (err) console.error("Owliver: request failed", err);
setSending(false);
/* ------------------------------------------------------- conversation */
const prev = messagesRef.current;
const last = prev[prev.length - 1];
// Drop the optimistic turn we echoed; it was never persisted.
const rolledBack = last && last.__optimistic ? prev.slice(0, -1) : prev;
setMessages(rolledBack);
// Only fall back to the prompt surface when nothing was actually answered.
// A failed follow-up must not yank the user away from the reply they are
// still reading — the compact error sits above the prompt either way.
if (!rolledBack.some((m) => m.role !== "user")) setViewMode("prompt");
// Carry the status through when the API gave one. A bare "couldn't complete
// that request" is indistinguishable between a missing agent, an expired
// session and a server fault, which is exactly what made this hard to place.
const status = err?.status ? ` (${err.status})` : "";
setError(`Owliver couldn't complete that request.${status}`);
}, [setSending]);
// A new page is a new context. Return to the prompt surface, drop the
// visible thread and invalidate the context we last sent, so Owliver always
// opens on what *this* page needs rather than on an answer about the last
// one. (The route is the only thing that resets this — navigating is a
// deliberate act, so it can't surprise the user mid-reply.)
//
// Keyed on the raw search *string*, not the URLSearchParams object: an
// identity change there would tear down a healthy in-flight turn.
// A new page is a new context: drop the thread, cancel any turn in flight and
// open on what *this* page needs. Keyed on the raw search string, not the
// URLSearchParams object, whose identity changes on every render.
useEffect(() => {
turnRef.current += 1;
setSending(false);
setViewMode("prompt");
setThinkingStage(null);
setMessages([]);
setFollowUps([]);
setError(null);
}, [pageName, location.search, setSending]);
/**
* The one submission path. `rawText` is whatever the user asked — typed into
* the prompt box or carried by a suggestion chip.
* The one submission path. `rawText` is whatever the user asked — typed, or
* carried by a suggestion, a follow-up or an in-answer action.
*/
const send = useCallback(async (rawText) => {
const text = (rawText || "").trim();
if (!text || isSendingRef.current) return;
const ticket = (turnRef.current += 1);
const live = () => turnRef.current === ticket;
lastSentRef.current = text;
setError(null);
setFollowUps([]);
setSending(true);
// An actual request — and only an actual request — moves Owliver into the
// response surface. Echo the user's turn first so a click on a suggestion
// produces immediate visible feedback; `failSend` rolls it back if the
// answer never arrives.
setViewMode("conversation");
// The thread as it stood *before* this turn — what a follow-up ("why?") is
// answered against.
const priorMessages = messagesRef.current;
setMessages((prev) => [...prev, { role: "user", content: text, __optimistic: true }]);
// The user's turn lands immediately — a click on a suggestion has to produce
// visible feedback before anything is computed.
const prior = messagesRef.current;
setMessages([...prior, { id: `you-${Date.now()}`, role: "user", content: text }]);
setThinkingStage(`${knowledge.working}…`);
try {
// Loaded on demand so the engine only ships to sessions that open Owliver.
const { getOwliverResponse } = await import("@/lib/owliver/owliverEngine");
const reply = await getOwliverResponse({
prompt: text,
pageContext,
conversation: priorMessages,
});
setMessages((prev) => [
// Nothing will echo this turn back, so it stops being provisional.
...prev.map((m) => (m.__optimistic ? { ...m, __optimistic: false } : m)),
{ id: `owliver-${Date.now()}`, role: "assistant", content: reply },
]);
setSending(false);
} catch (err) {
failSend(err);
const answer = await getOwliverAnswer({
prompt: text,
domainKey: domain.key,
domain,
signals,
pageContext,
conversation: prior,
});
if (!live()) return;
// Play the answer's own working lines ("Checking 27 opportunities…"), then
// hand over to the response surface.
const stages = answer.stages?.length ? answer.stages : [`${knowledge.working}…`];
for (const stage of stages) {
setThinkingStage(stage);
await wait(STAGE_MS);
if (!live()) return;
}
}, [pageContext, failSend, setSending]);
setThinkingStage(null);
const id = `owliver-${Date.now()}`;
setMessages((prev) => [
...prev,
{ id, role: "assistant", blocks: answer.blocks, topic: answer.topic, revealed: 1 },
]);
// Blocks land one at a time: the text of the answer, then the evidence it
// rests on, then the actions. The composer stays disabled until the answer
// is whole, so a follow-up is never asked against half an answer.
for (let i = 2; i <= answer.blocks.length; i += 1) {
await wait(BLOCK_MS);
if (!live()) return;
setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, revealed: i } : m)));
}
if (!live()) return;
setFollowUps(answer.followUps || []);
if (answer.error) setError("Something went wrong while analyzing this workspace.");
setSending(false);
}, [domain, knowledge, signals, pageContext, setSending]);
/** Re-run the prompt that failed, not a blank one. */
const retry = useCallback(() => {
@@ -241,47 +243,59 @@ export function OwliverProvider({ children }) {
send(text);
}, [send]);
// Opening always lands on the prompt-first surface, whatever happened in an
// earlier turn.
const open = useCallback(() => {
setHasOpened(true);
setIsOpen(true);
setViewMode("prompt");
setError(null);
}, []);
const open = useCallback(() => { setIsOpen(true); setError(null); }, []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => (isOpen ? close() : open()), [isOpen, open, close]);
const toggleExpanded = useCallback(() => setIsExpanded((v) => !v), []);
const newConversation = useCallback(() => {
turnRef.current += 1;
setSending(false);
setThinkingStage(null);
setMessages([]);
setFollowUps([]);
setError(null);
setViewMode("prompt");
}, [setSending]);
// Return to the prompt surface without discarding the thread.
const showInsight = useCallback(() => { setViewMode("prompt"); setError(null); }, []);
const hasThread = messages.length > 0;
// An answer normally names its own follow-ups. When it doesn't — the pages
// answered through the fallback adapter can't — the page's own remaining
// suggestions stand in, minus anything already asked in this thread. Offering
// back a question the user just asked is the fastest way to make a panel feel
// like it isn't listening.
const effectiveFollowUps = useMemo(() => {
if (followUps.length) return followUps;
const asked = new Set(
messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
);
return suggestions
.filter((s) => !asked.has((s.prompt || s.label || "").trim()))
.slice(0, 3);
}, [followUps, messages, suggestions]);
const value = useMemo(() => ({
isOpen, open, close, toggle,
isExpanded, toggleExpanded,
isAvailable: !isExcluded,
messages, send, retry, isSending, error,
newConversation, showInsight, registerPage,
atInsight: viewMode === "prompt",
// Whether there is a thread to go back to — drives the header's back
// control, so it only appears when it actually does something.
hasThread: messages.length > 0,
domain, pageName, pageContext, role,
signals, signalsLoading, signalsError,
insight, suggestions,
// conversation
messages, send, retry, isSending, error, thinkingStage,
followUps: effectiveFollowUps,
newConversation, hasThread,
lastTopic: lastTopicOf(messages),
// page context
registerPage, domain, knowledge, pageName, pageContext, role,
signals, signalsLoading, signalsError, read, suggestions,
placeholder: knowledge.placeholder,
contextLabel: knowledge.contextLabel,
workspaceTitle: knowledge.title,
}), [
isOpen, open, close, toggle, isExpanded, toggleExpanded, isExcluded,
messages, send, retry, isSending, error, newConversation, showInsight,
viewMode,
registerPage, domain, pageName, pageContext, role, signals,
signalsLoading, signalsError, insight, suggestions, user,
messages, send, retry, isSending, error, thinkingStage, effectiveFollowUps,
newConversation, hasThread, registerPage, domain, knowledge, pageName,
pageContext, role, signals, signalsLoading, signalsError, read, suggestions,
]);
return <OwliverContext.Provider value={value}>{children}</OwliverContext.Provider>;

View File

@@ -1,98 +1,97 @@
import { useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
ChatLayout,
ChatMessageList,
ChatMessage,
ChatComposer,
} from "@astryxdesign/core";
import { useOwliver } from "./OwliverProvider";
import OwliverHeader from "./OwliverHeader";
import OwliverInsight from "./OwliverInsight";
import OwliverContextBar from "./OwliverContextBar";
import OwliverEmptyState from "./OwliverEmptyState";
import OwliverResponse from "./blocks/OwliverResponse";
import OwliverSuggestions from "./OwliverSuggestions";
import OwliverAnswer from "./OwliverAnswer";
import OwliverAvatar from "./OwliverAvatar";
import OwliverComposer from "./OwliverComposer";
import OwliverThinking from "./OwliverThinking";
import OwliverLauncher from "./OwliverLauncher";
/** Owliver's own mark on every turn he speaks — Astryx slots it, KROW supplies it. */
const owliverMark = <OwliverAvatar size={20} rounded="rounded-md" />;
/**
* OwliverShell
* ------------
* Owliver's chat surface, built on Astryx's ChatLayout (@astryxdesign/core).
* OwliverShell — the AI workspace.
*
* Astryx supplies the structure and the chat behaviour; KROW supplies the
* branding and every piece of content. Nothing about the provider, the local
* engine, the page registry or the demo data changes — this file only decides
* how a turn is presented.
* ┌──────────────────────────────────────┐
* │ ✦ Owliver ⋯ × │ fixed — identity + page
* │ Saving Engine │
* │ ⛁ Using Saving Engine data ⌄ │ fixed — what is being read
* ├──────────────────────────────────────┤
* │ │
* │ conversation — text, metrics, cards, │ the only band that scrolls
* │ tables, charts, flows, actions │
* │ │
* ├──────────────────────────────────────┤
* │ Suggested follow-ups │ fixed — derived from the answer
* │ ( … ) ( … ) │
* │ ┌──────────────────────────────────┐ │
* │ │ Ask Owliver about Saving Engine… │ │ fixed
* │ │ ✦ Saving Engine [ ↑ ] │ │
* │ └──────────────────────────────────┘ │
* └──────────────────────────────────────┘
*
* Why ChatLayout does the work here:
* • With no `scrollRef`, its own root is the scroll container and the composer
* rides a sticky dock at the bottom. That is exactly the required contract —
* header pinned, conversation scrolling, composer always reachable — with no
* scroll plumbing of our own.
* • `ChatMessage` renders a flat sender-attributed row, not a bubble, which is
* the message hierarchy this needs.
* • `ChatComposer` already owns the input, the send button, the disabled state
* and an error status line, so `OwliverPrompt` and `OwliverError` are gone
* rather than reimplemented beside it.
* The conversation is the surface. Everything else is a thin band around it:
* the panel owns its own scrollport, so a long answer never lengthens the page
* behind it, and the composer is always one keystroke away from wherever the
* user has read to.
*
* `density="compact"` because this is a ~360px rail rather than a full-page
* chat; the default 'balanced' is tuned for a centred conversation column.
*
* The default state is not a special screen. It is the same ChatMessageList
* holding one assistant turn — the page read — followed by the same suggestion
* chips the reply state uses, so there is only ever one visual system.
*
* Placement (right-hand rail on desktop, bottom sheet on small screens) stays in
* `.krow-owliver-panel`; Astryx owns everything inside it.
* Two scrolling rules, both about not fighting the reader. New turns are
* followed automatically, but only while the user is already at the bottom —
* scroll up to re-read an earlier answer and the panel leaves you there.
*/
export default function OwliverShell() {
const {
isOpen, close, isAvailable, isExpanded, toggleExpanded,
domain, insight, suggestions, signalsLoading, signalsError,
atInsight, showInsight, hasThread, newConversation,
messages, send, isSending, error, retry,
knowledge, workspaceTitle, contextLabel, placeholder,
read, suggestions, signals, signalsLoading, signalsError,
hasThread, newConversation,
messages, send, isSending, error, retry, thinkingStage, followUps,
} = useOwliver();
const launcherRef = useRef(null);
const scrollRef = useRef(null);
const endRef = useRef(null);
const [following, setFollowing] = useState(true);
// "Following" means the user is reading the newest turn. Anything more than a
// short distance from the bottom means they have gone back for something, and
// the panel stops pulling them forward until they return.
const onScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
setFollowing(distance < 80);
}, []);
useEffect(() => {
if (!isOpen || !following) return;
endRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
}, [messages, thinkingStage, isSending, isOpen, following]);
// A new question is always followed, wherever the user had scrolled to.
const submit = useCallback((text) => {
setFollowing(true);
send(text);
}, [send]);
// The other floating controls step aside while the panel is open (the rule
// lives in index.css; this is what turns it on).
useEffect(() => {
const root = document.documentElement;
if (isOpen && isAvailable) root.setAttribute("data-owliver-open", "true");
else root.removeAttribute("data-owliver-open");
return () => root.removeAttribute("data-owliver-open");
}, [isOpen, isAvailable]);
if (!isAvailable) return null;
const placeholder =
domain.placeholder || `Ask Owliver about ${domain.title.toLowerCase()}...`;
// Follow-ups are the same page-derived suggestions minus anything already
// asked in this thread — offering back a question the user just asked is the
// fastest way to make a panel feel like it isn't listening.
const asked = new Set(
messages.filter((m) => m.role === "user").map((m) => (m.content || "").trim())
);
const followUps = suggestions
.filter((s) => !asked.has((s.prompt || s.label || "").trim()))
.slice(0, 3);
const composer = (
<ChatComposer
onSubmit={send}
placeholder={atInsight ? placeholder : "Ask a follow-up..."}
isDisabled={isSending}
density="compact"
elevation="none"
// A failure belongs to the composer, not the transcript: it reports on the
// control that failed and leaves the answer above it readable.
status={error ? { type: "error", message: error } : undefined}
/>
);
const facts = signals ? knowledge.contextFacts(signals) || [] : [];
return (
<>
{/* Dismissing the panel leaves the pill behind, so the page never loses
its way back to Owliver. Rendered unconditionally — the launcher owns
its own `!isOpen` guard inside an AnimatePresence, and gating it here
too would unmount it before it could animate out. */}
<OwliverLauncher ref={launcherRef} />
its way back to Owliver. */}
<OwliverLauncher />
<AnimatePresence>
{isOpen && (
@@ -100,93 +99,99 @@ export default function OwliverShell() {
key="ow-panel"
data-owliver=""
role="complementary"
aria-label={`Owliver — ${domain.title}`}
aria-label={`Owliver — ${workspaceTitle}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="krow-owliver-panel z-40 flex flex-col overflow-hidden"
data-expanded={isExpanded ? "true" : "false"}
data-mode={atInsight ? "prompt" : "conversation"}
>
<OwliverHeader
subtitle={domain.title}
workspace={workspaceTitle}
onClose={close}
onNewConversation={newConversation}
onShowInsight={showInsight}
onToggleExpanded={toggleExpanded}
isExpanded={isExpanded}
canGoBack={!atInsight}
hasThread={hasThread}
/>
{/* ChatLayout is the whole body: it scrolls internally and docks the
composer. `scrollButton={null}` because a 360px rail with a
pinned composer has nowhere to put a floating jump button. */}
<div className="krow-owliver-chat min-h-0 flex-1">
<ChatLayout
composer={composer}
density="compact"
scrollButton={null}
>
<ChatMessageList density="compact" isStreaming={isSending}>
{atInsight ? (
<ChatMessage sender="assistant" name="Owliver" avatar={owliverMark}>
<OwliverInsight
insight={insight}
loading={signalsLoading}
failed={signalsError}
/>
<OwliverSuggestions
suggestions={suggestions}
onSelect={send}
disabled={isSending}
/>
</ChatMessage>
) : (
<>
{messages.map((message, i) => (
<ChatMessage
key={message.id || i}
sender={message.role === "user" ? "user" : "assistant"}
name={message.role === "user" ? undefined : "Owliver"}
avatar={message.role === "user" ? undefined : owliverMark}
>
<OwliverAnswer message={message} />
</ChatMessage>
))}
<OwliverContextBar label={contextLabel} facts={facts} loading={signalsLoading} />
{/* Where an answer leaves you: the next useful question,
in the same chips as the default state. Held back
while a turn is in flight. */}
{!isSending && hasThread && followUps.length > 0 && (
<ChatMessage sender="assistant" name="Owliver" avatar={owliverMark}>
<OwliverSuggestions
heading="Follow up"
suggestions={followUps}
onSelect={send}
disabled={isSending}
/>
</ChatMessage>
)}
{error && (
<ChatMessage sender="system">
<button
type="button"
onClick={retry}
className="text-[12.5px] font-medium underline underline-offset-2"
style={{ color: "var(--ow-brand)" }}
>
Try that again
</button>
</ChatMessage>
)}
</>
{/* The one scrolling band. */}
<div
ref={scrollRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-y-auto px-3.5 py-3.5"
>
{!hasThread ? (
<OwliverEmptyState
read={read}
loading={signalsLoading}
failed={signalsError}
suggestions={suggestions}
onSelect={submit}
disabled={isSending}
workspaceTitle={workspaceTitle}
/>
) : (
<div className="space-y-5">
{messages.map((message, i) =>
message.role === "user" ? (
<div key={message.id || i} className="ow-fade-in">
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
You
</p>
<p className="mt-1 whitespace-pre-wrap text-[13px] leading-[1.55] text-slate-800">
{message.content}
</p>
</div>
) : (
<div key={message.id || i}>
<p className="mb-1.5 text-[9.5px] font-bold uppercase tracking-[0.12em] text-[#0039A6]">
Owliver
</p>
<OwliverResponse
blocks={(message.blocks || []).slice(0, message.revealed ?? message.blocks?.length)}
onPrompt={submit}
onRetry={retry}
disabled={isSending}
/>
</div>
)
)}
</ChatMessageList>
</ChatLayout>
{isSending && thinkingStage && <OwliverThinking stage={thinkingStage} />}
<div ref={endRef} className="h-px" />
</div>
)}
</div>
{/* Where an answer leaves you: the questions it made worth asking.
Only after a turn — before one, the suggestions live in the
conversation area where there is room to read them. */}
{hasThread && !isSending && followUps.length > 0 && (
<div className="flex-shrink-0 border-t border-slate-200 bg-slate-50/70 px-3.5 py-2">
<OwliverSuggestions
heading="Suggested follow-ups"
variant="row"
suggestions={followUps}
onSelect={submit}
disabled={isSending}
limit={3}
/>
</div>
)}
<OwliverComposer
onSubmit={submit}
placeholder={placeholder}
contextLabel={workspaceTitle}
disabled={isSending}
error={error}
onRetry={retry}
/>
</motion.section>
)}
</AnimatePresence>

View File

@@ -1,107 +1,59 @@
import {
Sparkles,
AlertTriangle,
Calendar,
DollarSign,
Users,
ChevronRight,
} from "lucide-react";
/**
* Returns a contextual icon matching the suggestion prompt text.
* Questions this page's data makes worth asking.
*
* They are prompts, not shortcuts: clicking one goes through the same `send()`
* path as typing it. The list is derived from the active page's knowledge pack,
* so it changes with the workspace — and after an answer it is replaced by that
* answer's own follow-ups, which is what stops the panel reading like a fixed
* command menu.
*/
function getSuggestionIcon(label = "") {
const text = String(label || "").toLowerCase();
if (
text.includes("short") ||
text.includes("gap") ||
text.includes("understaffed") ||
text.includes("conflict") ||
text.includes("risk") ||
text.includes("emergency")
) {
return AlertTriangle;
}
if (
text.includes("cost") ||
text.includes("budget") ||
text.includes("spend") ||
text.includes("$") ||
text.includes("payroll") ||
text.includes("labor")
) {
return DollarSign;
}
if (
text.includes("coverage") ||
text.includes("upcoming") ||
text.includes("week") ||
text.includes("schedule") ||
text.includes("shift")
) {
return Calendar;
}
if (
text.includes("staff") ||
text.includes("worker") ||
text.includes("candidate") ||
text.includes("applicant") ||
text.includes("roster")
) {
return Users;
}
if (
text.includes("summarize") ||
text.includes("overview") ||
text.includes("recap") ||
text.includes("report") ||
text.includes("health")
) {
return Sparkles;
}
return ChevronRight;
}
export default function OwliverSuggestions({
suggestions,
onSelect,
disabled,
heading = "Suggested questions",
limit,
variant = "wrap",
}) {
const items = (suggestions || [])
.filter(Boolean)
.map((item) => (typeof item === "string"
? { label: item, prompt: item }
: { label: item.label || item.prompt, prompt: item.prompt || item.label }))
.filter((item) => item.label);
export default function OwliverSuggestions({ suggestions, onSelect, disabled, heading }) {
if (!suggestions?.length) return null;
const sectionHeading = heading || "Suggested actions";
const shown = limit ? items.slice(0, limit) : items;
if (!shown.length) return null;
return (
<nav aria-label="Suggested actions" className="mt-3.5 mb-2">
<div className="flex items-center gap-1.5 mb-2.5">
<Sparkles className="w-3.5 h-3.5 text-[#0039A6]" />
<span className="text-[11px] font-bold uppercase tracking-wider text-slate-400">
{sectionHeading}
</span>
</div>
<nav aria-label={heading} className="ow-fade-in">
{heading && (
<p className="mb-1.5 text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
{heading}
</p>
)}
<div className="flex flex-wrap gap-2 items-center">
{suggestions.map((item, index) => {
const labelText = typeof item === "string" ? item : item.label || item.prompt || "";
const promptValue = typeof item === "string" ? item : item.prompt || item.label || "";
if (!labelText) return null;
const Icon = getSuggestionIcon(labelText);
const isPrimary = index === 0;
return (
<button
key={labelText || index}
type="button"
disabled={disabled}
onClick={() => onSelect(promptValue)}
className={`inline-flex items-center gap-2 px-3.5 py-2 rounded-xl text-[13px] font-semibold transition-all duration-150 cursor-pointer max-w-full text-left active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/40 disabled:opacity-50 disabled:cursor-not-allowed ${
isPrimary
? "bg-blue-50/90 text-[#0039A6] border border-[#0039A6]/35 hover:bg-blue-100/90 hover:border-[#0039A6]/60 shadow-2xs"
: "bg-white text-slate-700 border border-slate-200/90 hover:border-[#0039A6]/40 hover:bg-blue-50/60 hover:text-[#0039A6] shadow-2xs"
}`}
>
<Icon className={`w-3.5 h-3.5 shrink-0 ${isPrimary ? "text-[#0039A6]" : "text-slate-400"}`} />
<span className="truncate">{labelText}</span>
</button>
);
})}
{/* Docked, the chips wrap into the room the panel has. On the phone sheet
that room is vertical and scarce, so they become one scrolling row
instead of eating three lines of the conversation. */}
<div
className={
variant === "row"
? "ow-scroll-x flex flex-nowrap gap-1.5 md:flex-wrap"
: "flex flex-wrap gap-1.5"
}
>
{shown.map((item, i) => (
<button
key={`${item.label}-${i}`}
type="button"
disabled={disabled}
onClick={() => onSelect(item.prompt)}
className="max-w-full flex-shrink-0 rounded-full border border-slate-200 bg-white px-2.5 py-[5px] text-left text-[12px] font-medium text-slate-600 transition-colors duration-150 hover:border-[#0039A6]/40 hover:bg-blue-50/70 hover:text-[#0039A6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="block truncate">{item.label}</span>
</button>
))}
</div>
</nav>
);

View File

@@ -0,0 +1,25 @@
/**
* The working state: the line Owliver is currently on, and three dots.
*
* The line comes from the answer being composed ("Checking 27 opportunities…"),
* so it names real work on real page data rather than saying "thinking". It is
* announced politely so a screen reader follows the same stages a sighted user
* watches.
*/
export default function OwliverThinking({ stage }) {
return (
<div className="ow-fade-in" aria-live="polite">
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-[#0039A6]">Owliver</p>
<div className="mt-1 flex items-center gap-2">
<span key={stage} className="ow-fade-in text-[12.5px] font-medium text-slate-500">
{stage || "Analyzing…"}
</span>
<span className="flex items-center gap-[3px]" aria-hidden="true">
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" />
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" style={{ animationDelay: "160ms" }} />
<span className="ow-dot h-1 w-1 rounded-full bg-slate-400" style={{ animationDelay: "320ms" }} />
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { useNavigate } from "react-router-dom";
import { ArrowRight, RotateCw } from "lucide-react";
import { Label } from "./primitives";
/**
* What to do about the answer.
*
* Three action shapes, all of them real: a `route` navigates into the
* application, a `prompt` asks Owliver the follow-up through the same send path
* as typing it, and `retry` re-runs the question that failed. An action with
* none of those is not rendered — a button that does nothing is worse than no
* button.
*/
export default function OwliverActions({ block, onPrompt, onRetry, disabled }) {
const navigate = useNavigate();
const items = (block.items || []).filter((a) => a.route || a.prompt || a.retry);
if (!items.length) return null;
const run = (action) => {
if (action.retry) return onRetry?.();
if (action.route) return navigate(action.route);
if (action.prompt) return onPrompt?.(action.prompt);
};
return (
<section>
{block.title && <Label className="mb-1.5">{block.title}</Label>}
<div className="flex flex-wrap gap-1.5">
{items.map((action, i) => (
<button
key={`${action.label}-${i}`}
type="button"
disabled={disabled && !action.route}
onClick={() => run(action)}
className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-2.5 py-1.5 text-[12px] font-semibold text-slate-700 transition-colors duration-150 hover:border-[#0039A6]/40 hover:bg-blue-50/60 hover:text-[#0039A6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30 disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="truncate">{action.label}</span>
{action.retry ? (
<RotateCw className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
) : (
<ArrowRight className="h-3 w-3 flex-shrink-0 text-[#0039A6]" />
)}
</button>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowUpRight, ChevronDown } from "lucide-react";
import { Label, TONE_CHIP, TONE_BG } from "./primitives";
/**
* Record cards — an opportunity, an order, a risk.
*
* ┌───────────────────────────────┐
* │ Vendor consolidation HIGH │
* │ Vendor │
* │ $96,400 per year │
* │ ▇▇▇▇▇▇▇▁▁▁ 34% of total │
* │ Status Active · Effort medium │
* │ View → │
* └───────────────────────────────┘
*
* A card is interactive in two ways and both are real: the header expands the
* record's detail in place, and the action opens the record in the application.
* A card with no route renders no action rather than a dead button.
*/
export default function OwliverCards({ block }) {
if (!block.items?.length) return null;
return (
<section>
{block.title && <Label className="mb-1.5">{block.title}</Label>}
<div className="space-y-2">
{block.items.map((item, i) => (
<RecordCard key={item.id || `${item.title}-${i}`} card={item} />
))}
</div>
</section>
);
}
function RecordCard({ card }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const expandable = !!card.body;
const progressWidth = card.progress
? Math.max(2, Math.min(100, (Number(card.progress.value) / (Number(card.progress.max) || 1)) * 100))
: 0;
return (
<article className="ow-fade-in overflow-hidden rounded-xl border border-slate-200 bg-white transition-colors duration-150 hover:border-slate-300">
<div
role={expandable ? "button" : undefined}
tabIndex={expandable ? 0 : undefined}
onClick={expandable ? () => setOpen((v) => !v) : undefined}
onKeyDown={
expandable
? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }
: undefined
}
className={`px-3 pt-2.5 ${expandable ? "cursor-pointer" : ""}`}
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<h4 className="truncate text-[13px] font-semibold leading-tight text-slate-900">{card.title}</h4>
{card.subtitle && (
<p className="mt-0.5 truncate text-[11.5px] leading-tight text-slate-500">{card.subtitle}</p>
)}
</div>
{card.badge && (
<span
className={`flex-shrink-0 rounded border px-1.5 py-[2px] text-[9.5px] font-bold uppercase tracking-wider ${
TONE_CHIP[card.badgeTone] || TONE_CHIP.neutral
}`}
>
{card.badge}
</span>
)}
{expandable && (
<ChevronDown
className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-slate-400 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
/>
)}
</div>
{card.value && (
<p className="mt-2 flex items-baseline gap-1.5">
<span className="text-[20px] font-bold leading-none tabular-nums text-slate-900">{card.value}</span>
{card.valueLabel && <span className="text-[11px] font-medium text-slate-500">{card.valueLabel}</span>}
</p>
)}
{card.progress && (
<div className="mt-2">
<div className="h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${TONE_BG[card.progress.tone] || TONE_BG.brand}`}
style={{ width: `${progressWidth}%` }}
/>
</div>
{card.progress.label && (
<p className="mt-1 text-[11px] font-medium text-slate-500">{card.progress.label}</p>
)}
</div>
)}
{open && card.body && (
<p className="ow-fade-in mt-2 text-[12px] leading-relaxed text-slate-600">{card.body}</p>
)}
</div>
{(card.meta?.length > 0 || card.route) && (
<div className="mt-2.5 flex items-center justify-between gap-2 border-t border-slate-100 px-3 py-2">
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 text-[11px] text-slate-500">
{(card.meta || []).map((m, i) => (
<span key={`${m.label}-${i}`} className="inline-flex items-center gap-1">
{i > 0 && <span className="text-slate-300">·</span>}
<span className="text-slate-400">{m.label}</span>
<span className="font-medium text-slate-600">{m.value}</span>
</span>
))}
</p>
{card.route && (
<button
type="button"
onClick={() => navigate(card.route)}
className="flex flex-shrink-0 items-center gap-1 rounded text-[11px] font-semibold text-[#0039A6] transition-opacity hover:opacity-70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30"
>
{card.actionLabel || "View"}
<ArrowUpRight className="h-3 w-3" />
</button>
)}
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,83 @@
import {
Area, AreaChart, Bar, BarChart, CartesianGrid, Line, LineChart,
ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
import { Label } from "./primitives";
/**
* A compact chart, drawn with the charting library the application already
* ships (recharts) — no new dependency for one panel.
*
* At panel width almost everything a dashboard chart carries is noise, so this
* keeps four things: the series, a light horizontal grid, the category axis and
* a value on hover. Y ticks are formatted through the block's own `format`, so
* a money series reads "$40K" rather than "40000".
*/
const compact = (v) => {
const n = Number(v || 0);
if (Math.abs(n) >= 1000) return `${Math.round(n / 1000)}K`;
return String(n);
};
export default function OwliverChart({ block }) {
const data = block.data || [];
if (!data.length) return null;
const isMoney = block.format === "money";
const tick = (v) => (isMoney ? `$${compact(v)}` : compact(v));
const tipValue = (v) => (isMoney ? `$${Number(v).toLocaleString("en-US")}` : Number(v).toLocaleString("en-US"));
const axis = { stroke: "#94A3B8", fontSize: 10, tickLine: false, axisLine: false };
const Chart = block.variant === "bar" ? BarChart : block.variant === "line" ? LineChart : AreaChart;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3 py-3">
{(block.title || block.summary) && (
<div className="mb-2 flex items-baseline justify-between gap-2 px-0.5">
{block.title && <Label>{block.title}</Label>}
{block.summary && (
<span className="flex-shrink-0 text-[11px] font-semibold text-emerald-700">{block.summary}</span>
)}
</div>
)}
<div className="h-[148px] w-full">
<ResponsiveContainer width="100%" height="100%">
<Chart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -12 }}>
<defs>
<linearGradient id="owArea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#0039A6" stopOpacity={0.22} />
<stop offset="100%" stopColor="#0039A6" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} stroke="#F1F5F9" />
<XAxis dataKey="label" {...axis} />
<YAxis {...axis} tickFormatter={tick} width={46} />
<Tooltip
cursor={{ stroke: "#CBD5E1", strokeWidth: 1 }}
contentStyle={{
borderRadius: 10,
border: "1px solid #E2E8F0",
boxShadow: "0 4px 16px rgba(15,23,42,0.08)",
fontSize: 12,
padding: "6px 10px",
}}
labelStyle={{ fontSize: 10, color: "#94A3B8", fontWeight: 600, textTransform: "uppercase" }}
formatter={(value) => [tipValue(value), block.unit || "Value"]}
/>
{block.variant === "bar" && <Bar dataKey="value" fill="#0039A6" radius={[3, 3, 0, 0]} maxBarSize={26} />}
{block.variant === "line" && (
<Line type="monotone" dataKey="value" stroke="#0039A6" strokeWidth={2} dot={{ r: 2.5, fill: "#0039A6" }} activeDot={{ r: 4 }} />
)}
{(!block.variant || block.variant === "area") && (
<Area type="monotone" dataKey="value" stroke="#0039A6" strokeWidth={2} fill="url(#owArea)" dot={{ r: 2.5, fill: "#0039A6" }} activeDot={{ r: 4 }} />
)}
</Chart>
</ResponsiveContainer>
</div>
</section>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowUpRight, ChevronDown } from "lucide-react";
import { Label, TONE_BG, TONE_TEXT } from "./primitives";
/**
* A process, drawn as nodes and connectors rather than described in a
* paragraph. Vertical by default because the panel is tall and narrow, and each
* node is a real control: selecting one opens what that stage is doing right
* now, with its own figures and its own way into the application.
*
* ● Data sources
* │
* ● Identify spend
* │
* ▸ Detect opportunity ← selected
* ┌──────────────────────┐
* │ 27 opportunities │
* │ 12 high · 9 med · 6 │
* │ View opportunities → │
* └──────────────────────┘
*/
export default function OwliverFlow({ block }) {
const navigate = useNavigate();
const [active, setActive] = useState(null);
const nodes = block.nodes || [];
if (!nodes.length) return null;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3 py-3">
{block.title && <Label className="mb-2.5">{block.title}</Label>}
<ol className="space-y-0">
{nodes.map((node, i) => {
const isOpen = active === node.id;
const isLast = i === nodes.length - 1;
const detail = node.detail;
return (
<li key={node.id || i} className="relative">
<div className="flex gap-2.5">
{/* rail */}
<div className="flex flex-col items-center">
<span
className={`mt-[7px] h-2 w-2 flex-shrink-0 rounded-full ring-4 ring-white transition-transform duration-150 ${
TONE_BG[node.tone] || TONE_BG.brand
} ${isOpen ? "scale-125" : ""}`}
aria-hidden="true"
/>
{!isLast && <span className="w-px flex-1 bg-slate-200" aria-hidden="true" />}
</div>
<div className={`min-w-0 flex-1 ${isLast ? "pb-0" : "pb-3"}`}>
<button
type="button"
onClick={() => setActive(isOpen ? null : node.id)}
disabled={!detail}
aria-expanded={isOpen}
className={`group flex w-full items-start gap-2 rounded-lg px-2 py-1 text-left transition-colors duration-150 ${
detail ? "hover:bg-slate-50" : "cursor-default"
} ${isOpen ? "bg-blue-50/70" : ""} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0039A6]/30`}
>
<span className="min-w-0 flex-1">
<span className={`block truncate text-[12.5px] font-semibold leading-tight ${isOpen ? TONE_TEXT.brand : "text-slate-900"}`}>
{node.label}
</span>
{node.caption && (
<span className="mt-0.5 block truncate text-[11px] leading-tight text-slate-500">
{node.caption}
</span>
)}
</span>
{detail && (
<ChevronDown
className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-slate-300 transition-transform duration-150 group-hover:text-slate-500 ${isOpen ? "rotate-180" : ""}`}
/>
)}
</button>
{isOpen && detail && (
<div className="ow-fade-in mt-1.5 rounded-lg border border-slate-200 bg-slate-50/80 px-2.5 py-2">
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">
{detail.title}
</p>
{detail.metrics?.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1.5">
{detail.metrics.map((m, j) => (
<div key={`${m.label}-${j}`}>
<p className="text-[15px] font-bold leading-none tabular-nums text-slate-900">{m.value}</p>
<p className="mt-0.5 text-[10.5px] text-slate-500">{m.label}</p>
</div>
))}
</div>
)}
{(detail.lines || []).map((line, j) => (
<p key={j} className="mt-1.5 text-[11.5px] leading-relaxed text-slate-600">{line}</p>
))}
{detail.route && (
<button
type="button"
onClick={() => navigate(detail.route)}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-semibold text-[#0039A6] hover:opacity-70"
>
{detail.actionLabel || "Open"}
<ArrowUpRight className="h-3 w-3" />
</button>
)}
</div>
)}
</div>
</div>
</li>
);
})}
</ol>
</section>
);
}

View File

@@ -0,0 +1,56 @@
import {
TextBlock, NoteBlock, MetricBlock, ListBlock, ProgressBlock,
StatusBlock, TimelineBlock, CompareBlock,
} from "./primitives";
import OwliverCards from "./OwliverCards";
import OwliverTable from "./OwliverTable";
import OwliverChart from "./OwliverChart";
import OwliverFlow from "./OwliverFlow";
import OwliverActions from "./OwliverActions";
/**
* The response renderer.
*
* An Owliver answer is an array of typed blocks; this switches on `type` and
* nothing else. That is the whole point of the schema — a real model or API
* response that emits the same blocks renders here without a line changing, and
* an unknown block type is skipped rather than crashing the panel.
*
* Blocks appear one at a time as the provider reveals them (see
* `revealedBlocks` in OwliverProvider), which is what produces the staged
* "text first, then the cards" feel without any streaming transport.
*/
const RENDERERS = {
text: TextBlock,
note: NoteBlock,
metric: MetricBlock,
list: ListBlock,
progress: ProgressBlock,
status: StatusBlock,
timeline: TimelineBlock,
compare: CompareBlock,
cards: OwliverCards,
table: OwliverTable,
chart: OwliverChart,
flow: OwliverFlow,
actions: OwliverActions,
};
export default function OwliverResponse({ blocks = [], onPrompt, onRetry, disabled }) {
if (!blocks.length) return null;
return (
<div className="space-y-2.5">
{blocks.map((block, i) => {
const Renderer = RENDERERS[block?.type];
if (!Renderer) return null;
return (
<div key={`${block.type}-${i}`} className="ow-block-in">
<Renderer block={block} onPrompt={onPrompt} onRetry={onRetry} disabled={disabled} />
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { Fragment, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowUpRight, ChevronsUpDown } from "lucide-react";
import { Label, TONE_CHIP } from "./primitives";
/**
* A compact table, sized for a 360px panel.
*
* Three rules keep it usable there: at most a handful of columns, the whole
* grid scrolls horizontally inside its own container rather than widening the
* panel, and a row expands in place instead of opening anything. Sortable
* columns sort on the numeric value the block carries, never on the formatted
* string — "$96,400" does not sort.
*/
export default function OwliverTable({ block }) {
const navigate = useNavigate();
const [sort, setSort] = useState(null); // { key, dir }
const [expanded, setExpanded] = useState(null);
const columns = block.columns || [];
const rows = useMemo(() => {
const list = [...(block.rows || [])];
if (!sort) return list;
const dir = sort.dir === "asc" ? 1 : -1;
return list.sort((a, b) => {
const x = a[sort.key];
const y = b[sort.key];
if (typeof x === "number" && typeof y === "number") return (x - y) * dir;
return String(x ?? "").localeCompare(String(y ?? "")) * dir;
});
}, [block.rows, sort]);
if (!rows.length || !columns.length) return null;
const toggleSort = (key) =>
setSort((prev) =>
prev?.key === key ? { key, dir: prev.dir === "asc" ? "desc" : "asc" } : { key, dir: "desc" }
);
const display = (row, col) => row[`${col.key}Display`] ?? row[col.key];
return (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white">
{block.title && (
<div className="border-b border-slate-100 px-3 py-2">
<Label>{block.title}</Label>
</div>
)}
<div className="ow-scroll-x">
<table className="w-full min-w-[300px] border-collapse text-left">
<thead>
<tr className="border-b border-slate-100">
{columns.map((col) => (
<th
key={col.key}
scope="col"
className={`px-3 py-1.5 text-[9.5px] font-bold uppercase tracking-[0.1em] text-slate-400 ${
col.align === "right" ? "text-right" : "text-left"
}`}
>
{col.sortable ? (
<button
type="button"
onClick={() => toggleSort(col.key)}
className={`inline-flex items-center gap-1 transition-colors hover:text-slate-600 ${
sort?.key === col.key ? "text-[#0039A6]" : ""
}`}
>
{col.label}
<ChevronsUpDown className="h-2.5 w-2.5" />
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const key = row.id || i;
const isOpen = expanded === key;
const interactive = !!(row.detail || row.route);
return (
<Fragment key={key}>
<tr
onClick={interactive ? () => setExpanded(isOpen ? null : key) : undefined}
className={`border-b border-slate-50 last:border-0 ${
interactive ? "cursor-pointer transition-colors hover:bg-slate-50/70" : ""
}`}
>
{columns.map((col) => (
<td
key={col.key}
className={`px-3 py-2 align-top text-[12px] ${
col.align === "right"
? "whitespace-nowrap text-right font-semibold tabular-nums text-slate-900"
: "text-slate-700"
}`}
>
{col.badge ? (
<span className={`inline-block rounded border px-1.5 py-[1px] text-[9.5px] font-bold uppercase tracking-wide ${TONE_CHIP.neutral}`}>
{display(row, col)}
</span>
) : (
display(row, col)
)}
</td>
))}
</tr>
{isOpen && (
<tr className="border-b border-slate-50 bg-slate-50/60">
<td colSpan={columns.length} className="px-3 py-2">
{row.detail && (
<p className="text-[11.5px] leading-relaxed text-slate-600">{row.detail}</p>
)}
{row.route && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); navigate(row.route); }}
className="mt-1.5 inline-flex items-center gap-1 text-[11px] font-semibold text-[#0039A6] hover:opacity-70"
>
View record
<ArrowUpRight className="h-3 w-3" />
</button>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
{block.footnote && (
<p className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">{block.footnote}</p>
)}
</section>
);
}

View File

@@ -0,0 +1,248 @@
import ReactMarkdown from "react-markdown";
import { useNavigate } from "react-router-dom";
import { isValidRoute } from "@/lib/owliver/actions";
/**
* The small blocks: text, note, metric, list, progress, status, timeline and
* compare. Each one renders exactly what its block carries and nothing more —
* a block with no data renders nothing rather than an empty frame.
*
* Typography does the work here. Figures are large, tabular and dark; labels are
* small, uppercase and grey; prose sits between the two. That hierarchy is what
* makes a narrow panel readable without boxes around everything.
*/
export const TONE_TEXT = {
neutral: "text-slate-700",
positive: "text-emerald-700",
risk: "text-orange-700",
warning: "text-amber-700",
info: "text-[#0039A6]",
brand: "text-[#0039A6]",
};
export const TONE_BG = {
neutral: "bg-slate-100",
positive: "bg-emerald-500",
risk: "bg-orange-500",
warning: "bg-amber-500",
info: "bg-[#0039A6]",
brand: "bg-[#0039A6]",
};
export const TONE_CHIP = {
neutral: "border-slate-200 bg-slate-50 text-slate-600",
positive: "border-emerald-200 bg-emerald-50 text-emerald-700",
risk: "border-orange-200 bg-orange-50 text-orange-700",
warning: "border-amber-200 bg-amber-50 text-amber-700",
info: "border-[#0039A6]/25 bg-blue-50 text-[#0039A6]",
brand: "border-[#0039A6]/25 bg-blue-50 text-[#0039A6]",
};
export const Label = ({ children, className = "" }) => (
<p className={`text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400 ${className}`}>
{children}
</p>
);
/* ---------------------------------------------------------------- text */
export function TextBlock({ block }) {
const navigate = useNavigate();
if (!block.content) return null;
return (
<div className="ow-prose text-[13px] leading-[1.62] text-slate-700">
<ReactMarkdown
components={{
p: ({ children }) => <p className="m-0 mb-2 last:mb-0">{children}</p>,
strong: ({ children }) => <strong className="font-semibold text-slate-900">{children}</strong>,
ul: ({ children }) => <ul className="m-0 mb-2 list-disc space-y-1 pl-4 last:mb-0">{children}</ul>,
a: ({ href, children }) =>
isValidRoute(href) ? (
<a
href={href}
onClick={(e) => { e.preventDefault(); navigate(href); }}
className="font-medium text-[#0039A6] underline underline-offset-2"
>
{children}
</a>
) : (
<span>{children}</span>
),
}}
>
{block.content}
</ReactMarkdown>
</div>
);
}
export const NoteBlock = ({ block }) =>
block.text ? <p className="text-[11.5px] leading-relaxed text-slate-400">{block.text}</p> : null;
/* -------------------------------------------------------------- metric */
/** The headline figure. One number leads; `sub` qualifies it. */
export function MetricBlock({ block }) {
const trendTone = TONE_TEXT[block.trendTone] || TONE_TEXT.neutral;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
{block.label && <Label>{block.label}</Label>}
<div className="mt-1.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<p className="text-[30px] font-bold leading-none tracking-[-0.02em] tabular-nums text-slate-900">
{block.value}
</p>
{block.trend && (
<span className={`text-[12.5px] font-semibold tabular-nums ${trendTone}`}>{block.trend}</span>
)}
</div>
{block.caption && <p className="mt-1.5 text-[11.5px] text-slate-500">{block.caption}</p>}
{block.sub?.length > 0 && (
<div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 border-t border-slate-100 pt-2.5">
{block.sub.map((item, i) => (
<div key={`${item.label}-${i}`} className="min-w-0">
<p className="truncate text-[10.5px] font-medium uppercase tracking-wide text-slate-400">
{item.label}
</p>
<p className="mt-0.5 truncate text-[14.5px] font-bold tabular-nums text-slate-800">
{item.value}
</p>
</div>
))}
</div>
)}
</section>
);
}
/* ---------------------------------------------------------------- list */
export function ListBlock({ block }) {
if (!block.items?.length) return null;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-2.5">
{block.title && <Label className="mb-1.5">{block.title}</Label>}
<dl className="divide-y divide-slate-100">
{block.items.map((item, i) => (
<div key={`${item.label}-${i}`} className="flex items-center justify-between gap-3 py-1.5 first:pt-0 last:pb-0">
<dt className="min-w-0 flex-1 truncate text-[12.5px] text-slate-600">{item.label}</dt>
<dd className={`flex-shrink-0 text-[12.5px] font-semibold tabular-nums ${TONE_TEXT[item.tone] || "text-slate-900"}`}>
{item.value}
</dd>
</div>
))}
</dl>
</section>
);
}
/* ------------------------------------------------------------ progress */
/** Proportion, drawn. Bars are relative to the block's own max, never to 100. */
export function ProgressBlock({ block }) {
if (!block.items?.length) return null;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
{block.title && <Label className="mb-2.5">{block.title}</Label>}
<div className="space-y-2.5">
{block.items.map((item, i) => {
const max = Number(item.max) || 1;
const width = Math.max(2, Math.min(100, (Number(item.value) / max) * 100));
return (
<div key={`${item.label}-${i}`}>
<div className="flex items-baseline justify-between gap-3">
<p className="min-w-0 flex-1 truncate text-[12px] font-medium text-slate-700">{item.label}</p>
<p className="flex-shrink-0 text-[12px] font-semibold tabular-nums text-slate-900">
{item.caption ?? item.value}
</p>
</div>
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${TONE_BG[item.tone] || TONE_BG.brand}`}
style={{ width: `${width}%` }}
/>
</div>
</div>
);
})}
</div>
</section>
);
}
/* -------------------------------------------------------------- status */
export function StatusBlock({ block }) {
const map = {
positive: "border-emerald-200 bg-emerald-50/60",
risk: "border-orange-200 bg-orange-50/60",
warning: "border-amber-200 bg-amber-50/60",
neutral: "border-slate-200 bg-slate-50/60",
info: "border-[#0039A6]/20 bg-blue-50/60",
};
return (
<section className={`rounded-xl border px-3.5 py-3 ${map[block.tone] || map.neutral}`}>
<p className={`text-[10.5px] font-bold uppercase tracking-[0.1em] ${TONE_TEXT[block.tone] || TONE_TEXT.neutral}`}>
{block.label}
</p>
<p className="mt-1.5 text-[13px] leading-relaxed text-slate-700">{block.text}</p>
</section>
);
}
/* ------------------------------------------------------------ timeline */
export function TimelineBlock({ block }) {
if (!block.items?.length) return null;
return (
<section className="rounded-xl border border-slate-200 bg-white px-3.5 py-3">
{block.title && <Label className="mb-2.5">{block.title}</Label>}
<ol className="relative space-y-3 border-l border-slate-200 pl-3.5">
{block.items.map((item, i) => (
<li key={`${item.label}-${i}`} className="relative">
<span
className={`absolute -left-[19px] top-1 h-2 w-2 rounded-full ring-2 ring-white ${TONE_BG[item.tone] || TONE_BG.neutral}`}
aria-hidden="true"
/>
<p className="text-[9.5px] font-bold uppercase tracking-[0.12em] text-slate-400">{item.time}</p>
<p className="mt-0.5 text-[12.5px] font-semibold leading-tight text-slate-900">{item.label}</p>
{item.caption && <p className="mt-0.5 text-[11.5px] text-slate-500">{item.caption}</p>}
</li>
))}
</ol>
</section>
);
}
/* ------------------------------------------------------------- compare */
/** Two or three figures beside each other, for "which of these" questions. */
export function CompareBlock({ block }) {
if (!block.items?.length) return null;
return (
<section>
{block.title && <Label className="mb-1.5">{block.title}</Label>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{block.items.map((item, i) => (
<div
key={`${item.label}-${i}`}
className={`rounded-xl border px-2.5 py-2 ${
item.tone === "positive"
? "border-emerald-200 bg-emerald-50/50"
: "border-slate-200 bg-white"
}`}
>
<p className="truncate text-[11px] font-medium text-slate-500" title={item.label}>{item.label}</p>
<p className="mt-1 text-[16px] font-bold leading-none tabular-nums text-slate-900">{item.value}</p>
{item.caption && <p className="mt-1 text-[10.5px] leading-tight text-slate-500">{item.caption}</p>}
</div>
))}
</div>
</section>
);
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useMemo, useEffect, useRef } from "react";
import useOwliverPageContext from "@/components/owliver/useOwliverPageContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
@@ -288,6 +289,28 @@ export default function ClientTrendsReport({ events = [], invoices = [] }) {
revenuePerLaborHour, laborCostPct,
};
// Owliver answers this page from the figures the report has already computed
// for the period the reader has selected — the same objects the charts below
// render from, so the panel and the page cannot quote different numbers.
useOwliverPageContext("analytics", {
events: filteredEvents,
invoices: filteredInvoices,
metrics,
spendTimeline,
hoursTimeline,
byHub,
byDept,
byPosition,
period,
groupBy,
activeTab,
// Primitives the registration signature can see: the objects above are
// rebuilt on every render, and re-registering only when a collection's
// length changes would leave Owliver holding a stale period's figures.
spend: Math.round(totalSpend),
fill: Math.round(fillRatePct),
});
const handleExport = () => {
const rows = [
['Client Labor Report'], ['Generated', new Date().toISOString()], ['Period', period], [''],

View File

@@ -4,12 +4,6 @@
@tailwind components;
@tailwind utilities;
/* Astryx's stylesheet is NOT imported here. It has to load after Tailwind's
preflight — which resets `button { padding: 0; background: transparent }` and
`* { border-width: 0 }` at the same specificity as Astryx's atomic classes,
so loading it first left every Astryx button as bare text — and a CSS
`@import` is only valid at the top of a file, where it would lose that race.
It is imported after this file in src/main.jsx instead. */
/* :root {
@@ -215,7 +209,7 @@ button:focus-visible {
cannot move: only its own middle band scrolls, so its header and composer stay
put however long an answer runs.
Below `md` a 300px column would eat the viewport, so the SAME element becomes
Below `md` a 380px column would eat the viewport, so the SAME element becomes
an out-of-flow sheet at the bottom of the screen. `position: fixed` takes it
out of the flex row entirely, which is what lets <main> reclaim the full width
without the layout needing to know anything about it.
@@ -229,9 +223,9 @@ button:focus-visible {
to a hamburger, so 300px is what the page beside it can afford. Declared on
:root rather than on an open/closed attribute because the element only exists
while Owliver is open — there is no closed state to size. */
:root { --krow-owliver-w: 18.75rem; } /* 300px — tablet */
@media (min-width: 1024px) { :root { --krow-owliver-w: 21.25rem; } } /* 340px */
@media (min-width: 1280px) { :root { --krow-owliver-w: 22.5rem; } } /* 360px */
:root { --krow-owliver-w: 23.75rem; } /* 380px — tablet */
@media (min-width: 1024px) { :root { --krow-owliver-w: 25rem; } } /* 400px */
@media (min-width: 1280px) { :root { --krow-owliver-w: 26.25rem; } } /* 420px */
.krow-owliver-panel {
/* --- sheet form (below md) --- */
@@ -240,19 +234,21 @@ button:focus-visible {
right: 1rem;
bottom: var(--krow-dock-base);
height: auto;
/* Leave the page it is about visible above it, and cap the sheet so its own
middle band scrolls rather than the sheet growing off-screen. */
/* The conversation is the surface, so the sheet takes most of the screen —
capped, not full, so the page it is about stays visible above it and the
sheet's own middle band is what scrolls. */
max-height: min(
68dvh,
78dvh,
calc(100dvh - var(--krow-header-h) - var(--krow-dock-base) - 0.5rem)
);
/* Owliver's warm off-white, unchanged between the two placements — the chips
and message rows are all designed as white-on-this. */
background: #FAF9F5;
border: 1px solid #EAE6DE;
border-radius: 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.04);
/* A very light neutral, unchanged between the two placements: the insight,
order and command cards are all designed as white-on-this, so the panel
reads as one control surface rather than a stack of floating cards. */
background: #FAFBFC;
border: 1px solid #E2E8F0;
border-radius: 20px;
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.08), 0 1px 4px rgba(15, 23, 42, 0.04);
}
@media (min-width: 640px) {
@@ -260,7 +256,7 @@ button:focus-visible {
left: auto;
right: 1.5rem;
top: auto;
width: min(22.5rem, calc(100vw - 3rem));
width: min(23.75rem, calc(100vw - 3rem));
}
}
@@ -289,26 +285,46 @@ button:focus-visible {
where the header's expand control appears. */
@media (min-width: 1024px) {
.krow-owliver-panel[data-expanded="true"] {
flex-basis: 27.5rem;
width: 27.5rem;
flex-basis: 32rem;
width: 32rem;
}
}
/* Astryx's ChatLayout sizes itself to its parent, so the wrapper between it and
the panel has to pass the panel's height through rather than collapsing to
content. Two rules, and the library handles the scrolling from there. */
.krow-owliver-chat { display: flex; flex-direction: column; }
.krow-owliver-chat > .astryx-chat-layout { flex: 1 1 auto; min-height: 0; }
/* The panel's own scrollport. Its scrollbar is slim so a 400px rail does not
lose 15px of readable width to browser chrome. */
.krow-owliver-panel ::-webkit-scrollbar { width: 8px; }
.krow-owliver-panel ::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.28);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.krow-owliver-panel ::-webkit-scrollbar-track { background: transparent; }
/* ChatMessageList bottom-anchors a short transcript: it puts an empty
`flex-grow: 1` spacer before the first message so a two-line exchange sits
just above the composer. That is right for a full-page chat, where the
conversation is the page. In a 360px rail it reads as a mostly-empty panel
with the content stranded at the bottom, so here the spacer is collapsed and
content starts under the header. `:empty` picks out the spacer alone — every
real message has content — so nothing else in the list is affected. */
.krow-owliver-panel .astryx-chat-message-list > div > div:first-child:empty {
flex-grow: 0;
/* Motion: an insight card arrives, it does not perform. */
@keyframes ow-fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.ow-fade-in { animation: ow-fade-in 200ms ease-out both; }
@keyframes ow-dot {
0%, 60%, 100% { opacity: 0.25; transform: translateY(0); }
30% { opacity: 1; transform: translateY(-2px); }
}
.ow-dot { animation: ow-dot 1.1s ease-in-out infinite; }
/* A response block arriving. Each one lands on its own, which is what gives a
staged answer its "text first, then the evidence" feel without any streaming
transport behind it. */
@keyframes ow-block-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; }
}
.ow-block-in { animation: ow-block-in 260ms cubic-bezier(0.22, 1, 0.36, 1) both; }
@media (prefers-reduced-motion: reduce) {
.ow-block-in, .ow-fade-in { animation: none; }
}
/* ============================================================================
@@ -318,20 +334,20 @@ button:focus-visible {
Brand indigo is sampled from the KROW logo mark (#3851A4).
========================================================================== */
[data-owliver] {
--ow-canvas: #F7F4EF;
--ow-surface: #FFFDF9;
--ow-canvas: #FAFBFC;
--ow-surface: #FFFFFF;
--ow-surface-raised: #FFFFFF;
--ow-ink: #1B2333;
--ow-ink-muted: #6E7484;
--ow-ink-subtle: #9AA0AE;
--ow-line: #EAE4DA;
--ow-line-strong: #DDD5C7;
--ow-brand: #3851A4;
--ow-brand-deep: #2A3F82;
--ow-brand-wash: #EEF1F9;
--ow-ink: #0F172A;
--ow-ink-muted: #64748B;
--ow-ink-subtle: #94A3B8;
--ow-line: #E2E8F0;
--ow-line-strong: #CBD5E1;
--ow-brand: #0039A6;
--ow-brand-deep: #002D85;
--ow-brand-wash: #EFF4FE;
--ow-danger: #B4442F;
--ow-radius: 28px;
--ow-radius-sm: 16px;
--ow-radius: 20px;
--ow-radius-sm: 12px;
--ow-shadow: 0 1px 2px rgba(27, 35, 51, 0.04), 0 8px 24px rgba(27, 35, 51, 0.06);
--ow-shadow-lift: 0 2px 4px rgba(27, 35, 51, 0.05), 0 18px 48px rgba(27, 35, 51, 0.12);
color: var(--ow-ink);

View File

@@ -0,0 +1,273 @@
/**
* Owliver answer → structured operational model
* ---------------------------------------------
* The engine (lib/owliver/owliverEngine.js) is unchanged and still the only
* place answers are composed. This module is presentation: it reads the markdown
* the engine produced and recovers the operational shape hiding inside it, so
* the panel can render
*
* INSIGHT → EVIDENCE → ACTION
*
* rather than a paragraph of prose.
*
* Nothing is invented here. Every number in the model is one the engine already
* wrote: the lead's count, the parenthetical seat total, and the `a/b staffed`
* figures on each order line. If a line doesn't parse, it falls through to
* `paragraphs` and renders as text — degrading to prose is always safe.
*
* The line formats the engine emits (see the `orders` responder):
* - **Business** — Event Name (Hub), 08.17.2026, 6:00 PM – 11:30 PM · 3/10 staffed [id:x]
* **Business — Event Name** (Hub), 08.17.2026, 6:00 PM – 11:30 PM · 3/10 staffed [id:x]
*/
import { stripContext } from "./context";
import { extractActions } from "./actions";
const ID_TAG = /\[id:([^\]]+)\]/;
const HUB = /\(([^)]+)\)/;
const US_DATE = /\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/;
const TIME_RANGE = /(\d{1,2}:\d{2}\s*[AP]M)\s*[–—-]\s*(\d{1,2}:\d{2}\s*[AP]M)/i;
const RATIO = /(\d+)\s*\/\s*(\d+)/;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** "08.17.2026" → "Aug 17". The year is dropped: these are operational dates. */
const shortDate = (raw) => {
const m = String(raw || "").match(US_DATE);
if (!m) return raw || null;
const month = MONTHS[Number(m[1]) - 1];
return month ? `${month} ${Number(m[2])}` : raw;
};
const clean = (value) =>
String(value || "")
.replace(/\*\*/g, "")
.replace(/^[\s,·—–-]+|[\s,·—–-]+$/g, "")
.trim();
/**
* One order line → a card, or null when the line isn't an order.
* @returns {{id, business, name, hub, date, time, assigned, requested, gap, statusText}|null}
*/
export function parseOrderLine(raw) {
let text = String(raw || "").replace(/^\s*[-*]\s+/, "").trim();
if (!text) return null;
const id = text.match(ID_TAG)?.[1] || null;
text = text.replace(ID_TAG, "").trim();
// The trailing segment after the last "·" carries the staffing read.
let head = text;
let tail = "";
const split = text.lastIndexOf("·");
if (split > -1) {
head = text.slice(0, split);
tail = text.slice(split + 1).trim();
}
const ratio = tail.match(RATIO);
const assigned = ratio ? Number(ratio[1]) : null;
const requested = ratio ? Number(ratio[2]) : null;
const statusText = ratio ? null : clean(tail) || null;
// A line with neither a ratio nor an id is prose, not an order.
if (assigned == null && !id) return null;
const bold = head.match(/\*\*(.+?)\*\*/)?.[1] || "";
let rest = head.replace(/\*\*(.+?)\*\*/, " ").trim();
let business = bold;
let name = "";
const dash = bold.indexOf("—");
if (dash > -1) {
business = bold.slice(0, dash);
name = bold.slice(dash + 1);
}
const hub = rest.match(HUB)?.[1] || null;
if (hub) rest = rest.replace(HUB, " ");
const dateRaw = rest.match(US_DATE)?.[0] || null;
if (dateRaw) rest = rest.replace(US_DATE, " ");
const timeMatch = rest.match(TIME_RANGE);
const time = timeMatch ? `${timeMatch[1]} – ${timeMatch[2]}` : null;
if (timeMatch) rest = rest.replace(TIME_RANGE, " ");
if (!name) name = rest;
const gap =
assigned != null && requested != null ? Math.max(0, requested - assigned) : 0;
return {
id,
business: clean(business) || clean(name) || "Order",
name: clean(name),
hub: clean(hub) || null,
date: dateRaw ? shortDate(dateRaw) : null,
time,
assigned,
requested,
gap,
statusText,
};
}
/* ------------------------------------------------------------------ insight */
/** What the answer is *about*, taken from the words the engine chose. */
const LABELS = [
[/orders? on this page|on this page —/i, "Order summary", "neutral"],
[/staffing attention|need[s]? .*attention|headcount gap/i, "Staffing attention", "risk"],
[/at risk|unstaffed/i, "Upcoming at risk", "risk"],
[/partially staffed/i, "Partially staffed", "risk"],
[/pending confirmation|pending/i, "Pending confirmation", "pending"],
[/draft/i, "Draft orders", "neutral"],
[/conflict|double.?book/i, "Scheduling conflicts", "risk"],
[/scheduled ahead|scheduled/i, "Scheduled ahead", "neutral"],
[/seats? (still )?open|open seat/i, "Open headcount", "risk"],
];
/** The reassuring counterpart, when the answer is that nothing is wrong. */
const CLEAR_LABELS = [
[/conflict|double.?book/i, "No conflicts"],
[/pending/i, "Nothing pending"],
[/draft/i, "No drafts"],
[/at risk|unstaffed/i, "No immediate risk"],
[/partially staffed|attention|short of headcount/i, "No staffing gaps"],
];
const NEGATIVE_OPENER = /^(no |nothing |none |every |all |there (is|are) no)/i;
const labelFor = (text) => {
const hit = LABELS.find(([pattern]) => pattern.test(text));
return hit ? { label: hit[1], tone: hit[2] } : { label: "Order insight", tone: "neutral" };
};
const clearLabelFor = (text) => {
const hit = CLEAR_LABELS.find(([pattern]) => pattern.test(text));
return hit ? hit[1] : "All clear";
};
/**
* The lead sentence, read as a metric.
* "3 orders need staffing attention, ranked by open headcount gap:" →
* { count: 3, unit: "orders", label: "Staffing attention", tone: "risk" }
*/
function parseLead(text, cards) {
const trimmed = String(text || "").replace(/[:\s]+$/, "").trim();
if (!trimmed) return null;
const positive = cards.length === 0 && NEGATIVE_OPENER.test(trimmed);
if (positive) {
return {
tone: "positive",
label: clearLabelFor(trimmed),
count: null,
unit: null,
secondary: null,
text: trimmed,
};
}
// "4 upcoming orders …" keeps its qualifier; "3 orders need …" does not pick
// up the verb after it.
const opening =
trimmed.match(
/^(\d+)\s+((?:[a-zA-Z]+\s+)?(?:orders?|shifts?|seats?|conflicts?|invoices?|positions?|candidates?|workers?))\b/i
) || trimmed.match(/^(\d+)\s+([a-zA-Z]+)\b/);
const { label, tone } = labelFor(trimmed);
// "(18 open seats total)" — the engine's own second figure, when it gave one.
let secondary = null;
const paren = trimmed.match(/\(([^)]*\d[^)]*)\)/);
if (paren) {
const num = paren[1].match(/\d+/);
if (num) {
secondary = {
value: Number(num[0]),
unit: clean(paren[1].replace(/\d+/, "").replace(/\btotal\b/i, "")) || "open",
};
}
} else {
// "…so 51 seats are still open." — the engine's own figure, mid-sentence.
const stated = trimmed.match(/(\d+)\s+(?:open\s+seats?|seats?)\s+(?:are|is)?\s*(?:still\s+)?open/i);
if (stated) {
secondary = { value: Number(stated[1]), unit: "seats open" };
} else if (cards.length) {
const gaps = cards.reduce((sum, card) => sum + (card.gap || 0), 0);
if (gaps > 0) secondary = { value: gaps, unit: "seats open" };
}
}
// No figure to lead with and nothing resolved: this is conversational prose
// ("I can raise a new order…"), and dressing it as a metric card would be a
// lie about what the answer contains.
if (!opening && !secondary) return { tone: "prose", label: null, count: null, unit: null, secondary: null, text: trimmed };
return {
tone,
label,
count: opening ? Number(opening[1]) : null,
unit: opening ? opening[2].trim() : null,
secondary,
text: trimmed,
};
}
/* -------------------------------------------------------------------- model */
/**
* Turn one assistant message into blocks the panel can render.
*
* @param {string} content raw markdown from the engine
* @returns {{insight, cards, paragraphs, actions, raw}}
*/
export function buildAnswerModel(content) {
const raw = stripContext(content);
if (!raw) return { insight: null, cards: [], paragraphs: [], actions: [], raw: "" };
const lines = raw.split("\n");
const cards = [];
const paragraphs = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (/^[-*]\s+/.test(trimmed)) {
const card = parseOrderLine(trimmed);
if (card) {
cards.push(card);
continue;
}
paragraphs.push(trimmed.replace(/^[-*]\s+/, ""));
continue;
}
// A standalone bolded record ("which order needs the most attention?").
if (/^\*\*/.test(trimmed) && (RATIO.test(trimmed) || ID_TAG.test(trimmed))) {
const card = parseOrderLine(trimmed);
if (card) {
cards.push(card);
continue;
}
}
paragraphs.push(trimmed);
}
const lead = paragraphs.length ? paragraphs[0] : "";
const insight = parseLead(lead, cards);
const body = insight ? paragraphs.slice(1) : paragraphs;
return {
insight,
cards,
paragraphs: body,
actions: extractActions(raw),
raw,
};
}
export default buildAnswerModel;

96
src/lib/owliver/blocks.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Owliver response blocks
* -----------------------
* An Owliver answer is a list of typed blocks, never a string of markup. The
* renderer (components/owliver/blocks) switches on `type` and nothing else, so
* the same surface can be driven by today's local knowledge packs and by a real
* model/API tomorrow — the transport changes, the schema does not.
*
* type OwliverBlock =
* | { type: "text", content }
* | { type: "metric", label, value, trend?, caption?, sub? }
* | { type: "cards", items }
* | { type: "table", columns, rows }
* | { type: "chart", variant, data }
* | { type: "flow", nodes }
* | { type: "actions", items }
* | { type: "list", items }
* | { type: "progress", items }
* | { type: "status", tone, label, text }
* | { type: "timeline", items }
* | { type: "compare", items }
* | { type: "note", text }
*
* The factories below exist so a knowledge pack reads as a description of an
* answer rather than as object literals, and so a typo in a block type fails
* where it is written instead of silently rendering nothing.
*/
export const BLOCK_TYPES = [
"text", "metric", "cards", "table", "chart", "flow",
"actions", "list", "progress", "status", "timeline", "compare", "note",
];
/** Tone vocabulary shared by every block that carries one. */
export const TONES = ["neutral", "positive", "risk", "warning", "info"];
export const text = (content) => ({ type: "text", content });
export const note = (content) => ({ type: "note", text: content });
/**
* The headline figure of an answer.
* `sub` carries the qualifying figures ("Monthly $42,800 · Annualized …").
*/
export const metric = ({ label, value, trend, trendTone = "neutral", caption, sub = [] }) => ({
type: "metric", label, value, trend, trendTone, caption, sub,
});
/** Records: opportunities, orders, risks — anything the user can open. */
export const cards = (items, { title } = {}) => ({ type: "cards", title, items });
export const table = ({ title, columns, rows, footnote }) => ({
type: "table", title, columns, rows, footnote,
});
export const chart = ({ title, variant = "line", data, unit, summary, format = "number" }) => ({
type: "chart", title, variant, data, unit, summary, format,
});
/** A process, drawn. `nodes` are ordered; each may carry a `detail` panel. */
export const flow = ({ title, nodes, orientation = "vertical" }) => ({
type: "flow", title, nodes, orientation,
});
/** Buttons. A route navigates; a prompt asks Owliver the follow-up. */
export const actions = (items, { title } = {}) => ({ type: "actions", title, items });
export const list = ({ title, items }) => ({ type: "list", title, items });
export const progress = ({ title, items }) => ({ type: "progress", title, items });
export const status = ({ tone = "positive", label, text: body }) => ({
type: "status", tone, label, text: body,
});
export const timeline = ({ title, items }) => ({ type: "timeline", title, items });
export const compare = ({ title, items }) => ({ type: "compare", title, items });
/** Drop falsy entries so a pack can write `cond && block(...)` inline. */
export const blocks = (...entries) => entries.flat().filter(Boolean);
/* ------------------------------------------------------------- formatting */
export const money = (value, { compact = false } = {}) => {
const n = Number(value || 0);
if (compact && Math.abs(n) >= 1000) {
const k = n / 1000;
return `$${(Math.abs(k) >= 100 ? Math.round(k) : Number(k.toFixed(1)))}K`;
}
return `$${Math.round(n).toLocaleString("en-US")}`;
};
export const pct = (value, digits = 0) => `${Number(value || 0).toFixed(digits)}%`;
export const plural = (count, one, many) => `${count} ${count === 1 ? one : many}`;

View File

@@ -0,0 +1,550 @@
/**
* Operational Intelligence (Analytics) — page knowledge
* -----------------------------------------------------
* Answers about the Reports page from the figures that page has already
* computed: spend against budget, hours scheduled against hours worked, fill
* rate, and where the money went by hub, department and position.
*
* Every figure here is read from `signals` — the workspace derived in
* `analyticsWorkspace.js` from the orders and invoices the page is showing. A
* breakdown the page has no records for is not drawn, and the answer says so
* rather than modelling a distribution that nothing supports.
*/
import {
blocks, text, note, metric, chart, table, actions, list, progress, status,
money, pct, plural,
} from "../blocks";
const n = (v) => Number(v || 0);
const EMPTY = {
view: { period: "all", periodLabel: "all time", groupBy: "monthly", activeTab: "executive" },
totals: {}, timeline: [], hoursTimeline: [], byHub: [], byDept: [], byPosition: [],
};
const workspace = (signals) => (signals && signals.totals ? signals : EMPTY);
const completionRate = (t) => (n(t.events) ? (n(t.completed) / n(t.events)) * 100 : 0);
const nothingToRead = (topic, what) => ({
topic,
stages: ["Reading the numbers…"],
blocks: blocks(
text(`This page has no ${what} to read yet.`),
note("Owliver reads the orders and invoices the Reports page has loaded for the selected period. Widening the period is usually enough."),
actions([{ label: "Open reports", route: "/Reports" }])
),
followUps: [],
});
/* ================================================================= answers */
const answerSummary = (d) => {
const t = d.totals;
if (!t.events && !t.invoices) return nothingToRead("summary", "orders or invoices");
return {
topic: "summary",
stages: [`Reading ${plural(t.events, "order", "orders")} and ${plural(t.invoices, "invoice", "invoices")}…`, "Comparing against budget…"],
blocks: blocks(
text(`Across the ${d.view.periodLabel}, **${money(t.spend)}** of labor spend covers ${plural(t.events, "order", "orders")} — ${plural(t.completed, "completed", "completed")}, ${t.open} still open — at a ${pct(t.fillRatePct, 1)} fill rate.`),
metric({
label: "Labor spend",
value: money(t.spend),
trend: t.spendDelta == null ? undefined : `${t.spendDelta >= 0 ? "↑" : "↓"} ${pct(Math.abs(t.spendDelta), 1)}`,
trendTone: t.spendDelta == null ? "neutral" : t.spendDelta > 0 ? "risk" : "positive",
caption: t.budget ? `${pct(t.budgetUsedPct, 1)} of a ${money(t.budget)} budget` : d.view.periodLabel,
sub: [
{ label: "Invoices", value: String(t.invoices) },
{ label: "Labor cost", value: t.laborCostPct ? pct(t.laborCostPct, 1) : "—" },
],
}),
list({
title: "Where it stands",
items: [
{ label: "Orders completed", value: `${t.completed} of ${t.events}`, tone: t.open ? "neutral" : "positive" },
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: t.fillRatePct >= 95 ? "positive" : "risk" },
t.openShifts ? { label: "Open shifts", value: String(t.openShifts), tone: "risk" } : null,
t.scheduledHours ? { label: "Hours scheduled vs worked", value: `${t.scheduledHours} / ${t.workedHours}` } : null,
t.budget ? { label: "Budget remaining", value: money(t.budgetLeft), tone: t.budgetLeft >= 0 ? "positive" : "risk" } : null,
].filter(Boolean),
}),
actions([
d.byDept.length ? { label: "Break it down by department", prompt: "Break spend down by department" } : null,
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
].filter(Boolean))
),
followUps: [
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
].filter(Boolean),
};
};
const answerChanged = (d) => {
const t = d.totals;
if (d.timeline.length < 2) {
return {
topic: "changed",
stages: ["Reading order and invoice history…"],
blocks: blocks(
text("There is not enough dated history in the selected period to read a trend."),
note(d.timeline.length
? "Only one bucket of spend falls inside this period, so there is nothing to compare it against."
: "No invoice or order in this period carries a date."),
actions([{ label: "Give me a summary", prompt: "Give me a summary" }])
),
followUps: [{ label: "Give me a summary", prompt: "Give me a summary" }],
};
}
const first = d.timeline[0];
const last = d.timeline[d.timeline.length - 1];
const growth = first.value ? ((last.value - first.value) / first.value) * 100 : null;
return {
topic: "changed",
stages: ["Reading order and invoice history…", "Comparing against the prior period…"],
blocks: blocks(
text(`Spend moved from ${money(first.value, { compact: true })} in ${first.label} to **${money(last.value, { compact: true })}** in ${last.label}${growth == null ? "" : `, ${growth >= 0 ? "up" : "down"} ${pct(Math.abs(growth), 1)} across the period`}.`),
chart({
title: "Labor spend",
variant: "area",
data: d.timeline,
format: "money",
summary: `${d.view.periodLabel} · ${plural(d.timeline.length, "bucket", "buckets")}`,
}),
metric({
label: "Completion rate",
value: pct(completionRate(t), 1),
caption: `${t.completed} of ${t.events} orders`,
sub: [
{ label: "Invoices", value: String(t.invoices) },
{ label: "Billed", value: money(t.spend, { compact: true }) },
],
}),
actions([
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
{ label: "Open reports", route: "/Reports" },
])
),
followUps: [
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
].filter(Boolean),
};
};
const answerRevenue = (d) => {
const t = d.totals;
if (!t.invoices && !t.spend) return nothingToRead("revenue", "invoices");
return {
topic: "revenue",
stages: [`Reading ${plural(t.invoices, "invoice", "invoices")}…`],
blocks: blocks(
text(`**${money(t.spend)}** billed across ${plural(t.invoices, "invoice", "invoices")} in the ${d.view.periodLabel}.`),
d.timeline.length > 1 ? chart({
title: "Billed value",
variant: "area",
data: d.timeline,
format: "money",
summary: `${plural(d.timeline.length, "bucket", "buckets")} across the period`,
}) : null,
metric({
label: "Average invoice",
value: money(t.invoices ? t.spend / t.invoices : 0),
caption: `per invoice, ${d.view.periodLabel}`,
sub: [
t.revenuePerLaborHour ? { label: "Rev / labor hr", value: money(t.revenuePerLaborHour) } : null,
t.laborCostPct ? { label: "Labor cost", value: pct(t.laborCostPct, 1) } : null,
].filter(Boolean),
}),
actions([{ label: "Open invoices", route: "/Invoices" }])
),
followUps: [
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
].filter(Boolean),
};
};
const answerMargin = (d) => {
const t = d.totals;
/**
* The leak, from the three things this page can actually measure: hours paid
* for above what was scheduled, shifts left open, and orders that went short.
* Each is worth a number only where the records carry one.
*/
const leaks = [
t.overtimeHours > 0 && {
label: "Hours above schedule",
value: t.overtimeHours,
caption: `${plural(t.overtimeHours, "hour", "hours")} worked beyond ${t.scheduledHours} scheduled`,
},
t.openShifts > 0 && {
label: "Open shifts",
value: t.openShifts,
caption: `${plural(t.openShifts, "shift", "shifts")} unfilled at a ${pct(t.fillRatePct, 1)} fill rate`,
},
t.noShows > 0 && {
label: "Orders that went short",
value: t.noShows,
caption: `${plural(t.noShows, "order", "orders")} ran below requested headcount`,
},
t.budget && t.budgetLeft < 0 && {
label: "Over budget",
value: Math.abs(t.budgetLeft),
caption: `${money(Math.abs(t.budgetLeft))} above the ${money(t.budget)} budget`,
},
].filter(Boolean);
if (!leaks.length) {
return {
topic: "margin",
stages: ["Checking spend against billed value…"],
blocks: blocks(
text("Nothing on this page is currently leaking margin."),
status({
tone: "positive",
label: "No leak detected",
text: `Every shift was filled, no hours were worked above schedule, and spend is ${t.budget ? `${pct(t.budgetUsedPct, 1)} of budget` : "within the tracked window"}.`,
}),
actions([{ label: "Give me a summary", prompt: "Give me a summary" }])
),
followUps: [{ label: "Give me a summary", prompt: "Give me a summary" }],
};
}
const worst = leaks[0];
return {
topic: "margin",
stages: ["Checking spend against billed value…"],
blocks: blocks(
text(`${plural(leaks.length, "thing is", "things are")} costing margin on the records this page tracks:`),
progress({
title: "Where it is going",
items: leaks.map((l) => ({
label: l.label,
value: l.value,
max: Math.max(...leaks.map((x) => x.value)),
caption: l.caption,
tone: l === worst ? "brand" : "neutral",
})),
}),
t.hoursSavings ? note(`Hours saved against schedule are worth ${money(t.hoursSavings)} at the average rate this period.`) : null,
actions([
{ label: "Open Saving Engine", route: "/SavingsEngine" },
{ label: "Open reports", route: "/Reports" },
])
),
followUps: [
d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
].filter(Boolean),
};
};
const answerAttention = (d) => {
const t = d.totals;
if (!t.events && !t.invoices) return nothingToRead("attention", "orders or invoices");
const flags = [
t.open > 0 && { label: "Orders still open", value: String(t.open), tone: "risk" },
t.openShifts > 0 && { label: "Unfilled shifts", value: String(t.openShifts), tone: "risk" },
t.noShows > 0 && { label: "Orders that went short", value: String(t.noShows), tone: "risk" },
t.budget && t.budgetLeft < 0 && { label: "Over budget by", value: money(Math.abs(t.budgetLeft)), tone: "risk" },
t.fillRatePct < 95 && { label: "Fill rate", value: pct(t.fillRatePct, 1), tone: "risk" },
].filter(Boolean);
return {
topic: "attention",
stages: ["Scanning for anomalies…"],
blocks: blocks(
text(flags.length
? `${plural(flags.length, "thing needs", "things need")} a look on this page.`
: `Nothing on this page needs intervention — ${t.completed} of ${plural(t.events, "order", "orders")} completed at a ${pct(t.fillRatePct, 1)} fill rate.`),
list({
title: flags.length ? "Worth a look" : "Where it stands",
items: flags.length ? flags : [
{ label: "Completed", value: String(t.completed), tone: "positive" },
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: "positive" },
{ label: "Invoices tracked", value: String(t.invoices) },
],
}),
actions([{ label: "Open reports", route: "/Reports" }])
),
followUps: [
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
].filter(Boolean),
};
};
/**
* The breakdown answers. One responder, three groupings, chosen by the words
* used — a department question and a hub question are the same reading of the
* same records at a different grain.
*/
const breakdown = (d, key, label) => {
const rows = d[key] || [];
if (!rows.length) return nothingToRead(`by-${key}`, `${label} breakdown`);
const total = rows.reduce((s, r) => s + n(r.spend), 0);
const lead = rows[0];
return {
topic: "breakdown",
stages: [`Grouping spend by ${label}…`],
blocks: blocks(
text(`**${lead.name}** is the largest ${label} on this page at ${money(lead.spend)}${total ? `, ${pct((n(lead.spend) / total) * 100, 1)} of ${money(total)} tracked` : ""}.`),
table({
title: `Spend by ${label}`,
columns: [
{ key: "name", label: label.charAt(0).toUpperCase() + label.slice(1), align: "left" },
{ key: "spend", label: "Spend", align: "right", sortable: true, numeric: true },
{ key: "count", label: key === "byHub" ? "Shifts" : "Headcount", align: "right", numeric: true },
],
rows: rows.map((r, i) => ({
id: `${key}-${i}`,
name: r.name,
spend: n(r.spend),
spendDisplay: money(r.spend),
count: n(r.count ?? r.shifts),
})),
footnote: "Sort by spend",
}),
actions([{ label: "Open reports", route: "/Reports" }])
),
followUps: [
key !== "byDept" && d.byDept.length && { label: "Which department costs most?", prompt: "Which department costs the most?" },
key !== "byPosition" && d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
].filter(Boolean),
};
};
const answerByDept = (d) => breakdown(d, "byDept", "department");
const answerByPosition = (d) => breakdown(d, "byPosition", "position");
const answerByHub = (d) => breakdown(d, "byHub", "hub");
const answerHours = (d) => {
const t = d.totals;
if (!t.scheduledHours) return nothingToRead("hours", "scheduled hours");
return {
topic: "hours",
stages: ["Reading scheduled against worked hours…"],
blocks: blocks(
text(`**${plural(t.scheduledHours, "hour", "hours")} scheduled** against ${plural(t.workedHours, "hour", "hours")} worked — a ${t.savedHours >= 0 ? "saving" : "overrun"} of ${plural(Math.abs(t.savedHours), "hour", "hours")}.`),
metric({
label: t.savedHours >= 0 ? "Hours saved" : "Hours over",
value: String(Math.abs(t.savedHours)),
caption: t.hoursSavings ? `${money(Math.abs(t.hoursSavings))} at the period's average rate` : d.view.periodLabel,
sub: [
{ label: "Scheduled", value: String(t.scheduledHours) },
{ label: "Worked", value: String(t.workedHours) },
],
}),
list({
title: "Coverage",
items: [
{ label: "Fill rate", value: pct(t.fillRatePct, 1), tone: t.fillRatePct >= 95 ? "positive" : "risk" },
t.openShifts ? { label: "Open shifts", value: String(t.openShifts), tone: "risk" } : null,
t.overtimeHours ? { label: "Hours above schedule", value: String(t.overtimeHours), tone: "risk" } : null,
].filter(Boolean),
}),
actions([{ label: "Open reports", route: "/Reports" }])
),
followUps: [
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
].filter(Boolean),
};
};
const answerVolume = (d) => {
const t = d.totals;
if (!t.events) return nothingToRead("volume", "orders");
return {
topic: "volume",
stages: ["Reading order history…"],
blocks: blocks(
text(`${plural(t.events, "order", "orders")} tracked in the ${d.view.periodLabel}, ${t.completed} completed.`),
table({
title: "Tracked volume",
columns: [
{ key: "label", label: "Measure", align: "left" },
{ key: "value", label: "Count", align: "right", sortable: true, numeric: true },
],
rows: [
{ id: "events", label: "Orders tracked", value: t.events, valueDisplay: String(t.events) },
{ id: "completed", label: "Completed", value: t.completed, valueDisplay: String(t.completed) },
{ id: "open", label: "Still open", value: t.open, valueDisplay: String(t.open) },
{ id: "invoices", label: "Invoices", value: t.invoices, valueDisplay: String(t.invoices) },
],
})
),
followUps: [
{ label: "Give me a summary", prompt: "Give me a summary" },
{ label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
],
};
};
/* ============================================================== dispatcher */
const ROUTES = [
[/\bdepartment|dept\b/i, answerByDept],
[/\bposition|role\b/i, answerByPosition],
[/\bhub|location|site|venue\b/i, answerByHub],
[/\bmargin|leak|profit|overrun|waste\b/i, answerMargin],
[/\bhours?|overtime|scheduled vs|utilisation|utilization\b/i, answerHours],
[/\brevenue|billed|invoice value|income|spend\b/i, answerRevenue],
[/\bchanged|this week|compared|trend|over time|growth\b/i, answerChanged],
[/\battention|risk|anomal|unusual|watch|concern\b/i, answerAttention],
[/\bvolume|how many|throughput|completed|count\b/i, answerVolume],
[/\bsummar|overview|recap|status|how (are|is) things|break ?down\b/i, answerSummary],
];
/**
* What a pronoun means, given what Owliver just said — the same mechanism the
* other packs use, so a follow-up asked here stays on this page's records.
*/
const FROM_TOPIC = {
summary: [
[/\bbiggest|most|largest|worst\b/i, answerByDept],
[/\bchang|trend|over time\b/i, answerChanged],
[/\brisk|attention\b/i, answerAttention],
[/\bhours?\b/i, answerHours],
],
changed: [
[/\bcaused|why|driver|where\b/i, answerMargin],
[/\bbiggest|most|largest\b/i, answerByDept],
],
breakdown: [
[/\bwhy|caused\b/i, answerMargin],
[/\bhours?\b/i, answerHours],
],
margin: [
[/\bbiggest|most|largest|worst\b/i, answerByDept],
[/\bhours?\b/i, answerHours],
],
attention: [
[/\bbiggest|most|worst|first\b/i, answerMargin],
[/\bhours?\b/i, answerHours],
],
hours: [
[/\bbiggest|most|largest\b/i, answerByPosition],
[/\bcost|money|spend\b/i, answerMargin],
],
revenue: [
[/\bbiggest|most|largest\b/i, answerByDept],
[/\bchang|trend\b/i, answerChanged],
],
volume: [
[/\bbiggest|most|largest\b/i, answerByDept],
],
};
const FOLLOW_UP = /\b(one|ones|these|those|them|it|that|this)\b/i;
const answerUnknown = (d) => ({
topic: null,
stages: ["Reading the numbers…"],
blocks: blocks(
text("I can analyse the metrics tracked in this workspace, but I couldn't map that question onto them."),
actions([
{ label: "Summary", prompt: "Give me a summary" },
d.timeline.length > 1 ? { label: "Trends", prompt: "What changed over the period?" } : null,
d.byDept.length ? { label: "Departments", prompt: "Which department costs the most?" } : null,
{ label: "Margin", prompt: "Where is margin leaking?" },
{ label: "Risks", prompt: "What in the numbers needs my attention?" },
].filter(Boolean), { title: "Try asking about" })
),
followUps: [],
});
/* ================================================================== export */
export const analytics = {
key: "analytics",
title: "Operational Intelligence",
placeholder: "Ask Owliver about trends, metrics, or performance...",
contextLabel: "Using Analytics data",
working: "Reading the numbers",
contextFacts: (signals) => {
const t = workspace(signals).totals;
return [
{ value: n(t.events), label: "orders tracked" },
{ value: n(t.completed), label: "completed" },
{ value: n(t.invoices), label: "invoices" },
].filter((f) => f.value > 0);
},
read: (signals) => {
const d = workspace(signals);
const t = d.totals;
if (!t.events && !t.invoices) {
return {
tone: "neutral",
headline: "Nothing tracked in the selected period.",
lines: ["Owliver reads the orders and invoices this page has loaded."],
stat: null,
};
}
const lead = d.byDept[0] || d.byHub[0];
return {
tone: t.fillRatePct < 95 || (t.budget && t.budgetLeft < 0) ? "warning" : "neutral",
headline: `${plural(t.completed, "order", "orders")} completed out of ${t.events} tracked.`,
lines: [
`**${money(t.spend)}** of labor spend across ${plural(t.invoices, "invoice", "invoices")} in the ${d.view.periodLabel}.`,
lead
? `**${lead.name}** is the largest cost centre at ${money(lead.spend)}.`
: `**${pct(completionRate(t), 1)} completion rate** across the tracked window.`,
t.openShifts ? `**${plural(t.openShifts, "shift is", "shifts are")}** still unfilled.` : null,
].filter(Boolean),
stat: { label: "Billed", value: money(t.spend, { compact: true }) },
};
},
/** Derived, so a chip is only offered when the page can answer it. */
suggestions: (signals) => {
const d = workspace(signals);
const t = d.totals;
const dept = d.byDept[0];
return [
{ label: "Give me a summary", prompt: "Give me a summary" },
d.timeline.length > 1 && { label: "What changed over the period?", prompt: "What changed over the period?" },
dept && { label: `Why is ${dept.name} the biggest cost?`, prompt: "Which department costs the most?" },
d.byPosition.length && { label: "Which position costs most?", prompt: "Which position costs the most?" },
(t.openShifts > 0 || t.fillRatePct < 95) && { label: "What needs attention?", prompt: "What in the numbers needs my attention?" },
{ label: "Where is margin leaking?", prompt: "Where is margin leaking?" },
t.scheduledHours > 0 && { label: "Scheduled vs worked hours", prompt: "Show scheduled against worked hours" },
].filter(Boolean);
},
answer({ prompt, signals, lastTopic }) {
const d = workspace(signals);
const q = String(prompt || "");
if (FOLLOW_UP.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
if (hit) return hit[1](d);
}
const route = ROUTES.find(([pattern]) => pattern.test(q));
return route ? route[1](d) : answerUnknown(d);
},
};
export default analytics;

View File

@@ -0,0 +1,216 @@
/**
* Analytics — workspace derivation
* --------------------------------
* The Reports page's own records, read into the shape Owliver answers from.
* Same contract as `savingsWorkspace.js`, and for the same reason:
*
* the page `ClientTrendsReport` publishes the figures and series it has
* already computed to draw its own charts, together with the
* period the reader has selected
* the route `DOMAINS.analytics.loadSignals()` fetches Events and Invoices
* and recomputes those same figures here
*
* Where the page hands over a figure, it is used as-is — the page computed it to
* draw a chart, and recomputing it slightly differently is how a panel ends up
* contradicting the page beside it. Only the route-level path derives.
*/
const num = (v) => Number(v || 0);
const round = (v) => Math.round(num(v));
const PERIOD_LABEL = {
"7d": "last 7 days", "30d": "last 30 days", "90d": "last 90 days",
ytd: "year to date", all: "all time",
};
const asDate = (value) => {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
};
/* --------------------------------------------------------------- shift maths */
/** Scheduled hours on one order: hours × headcount, across every shift role. */
const scheduledHoursOf = (event) =>
(event.shifts || []).reduce(
(s, sh) => s + (sh.roles || []).reduce((sr, r) => sr + num(r.hours) * num(r.count), 0), 0
);
const requestedCountOf = (event) =>
(event.shifts || []).reduce(
(sc, sh) => sc + (sh.roles || []).reduce((sr, r) => sr + num(r.count), 0), 0
);
/**
* Worked hours, on the report's own model.
*
* Reproduced from `ClientTrendsReport` rather than reinvented — including its
* per-order variance term, which is a deterministic function of the order name.
* A different formula here would put a different number in the panel than the
* chart shows, which is the one outcome worth going to this length to avoid.
*/
const workedHoursOf = (event, avgHours) => {
const variance = 0.92 + (Math.abs(event.event_name?.charCodeAt(0) || 65) % 8) * 0.01;
return num((event.assigned_staff || []).length) * avgHours * variance;
};
/* ------------------------------------------------------------ raw derivation */
function deriveFromRecords(events, invoices) {
const revenue = invoices.reduce((s, i) => s + num(i.amount), 0);
const orderValue = events.reduce((s, e) => s + num(e.total), 0);
const scheduledHours = events.reduce((s, e) => s + scheduledHoursOf(e), 0);
const workedHours = events.reduce((s, e) => {
const count = requestedCountOf(e);
const avg = count > 0 ? scheduledHours / count : 5;
return s + workedHoursOf(e, avg);
}, 0);
const scheduledShifts = events.reduce((s, e) => s + num(e.requested), 0);
const workedShifts = events.reduce((s, e) => s + (e.assigned_staff || []).length, 0);
const savedHours = scheduledHours - workedHours;
const avgRate = revenue > 0 && workedHours > 0 ? revenue / workedHours : 28;
return {
spend: revenue,
prevSpend: 0,
spendDelta: null,
budget: revenue > 0 ? Math.round(revenue * 1.148) : 0,
scheduledHours,
workedHours,
savedHours,
hoursSavings: savedHours * avgRate,
fillRatePct: scheduledShifts > 0 ? (workedShifts / scheduledShifts) * 100 : 100,
openShifts: Math.max(scheduledShifts - workedShifts, 0),
overtimeHours: workedHours > scheduledHours ? workedHours - scheduledHours : 0,
noShows: events.filter((e) => (e.assigned_staff || []).length < num(e.requested)).length,
revenuePerLaborHour: workedHours > 0 ? orderValue / workedHours : 0,
laborCostPct: orderValue > 0 ? (revenue / orderValue) * 100 : 0,
};
}
/** Monthly spend, from the dates the invoices and orders actually carry. */
function deriveTimeline(events, invoices) {
const buckets = new Map();
const put = (date, amount) => {
const d = asDate(date);
if (!d) return;
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const entry = buckets.get(key)
|| { key, label: d.toLocaleDateString("en-US", { month: "short" }), value: 0 };
entry.value += num(amount);
buckets.set(key, entry);
};
for (const i of invoices) put(i.issue_date, i.amount);
if (!buckets.size) for (const e of events) put(e.date, e.total);
return [...buckets.values()]
.sort((a, b) => a.key.localeCompare(b.key))
.slice(-6)
.map(({ label, value }) => ({ label, value: round(value) }));
}
const groupSpend = (events, pick) => {
const map = new Map();
for (const e of events) {
const key = pick(e) || "Unknown";
const entry = map.get(key) || { name: key, spend: 0, count: 0 };
entry.spend += num(e.total);
entry.count += (e.assigned_staff || []).length;
map.set(key, entry);
}
return [...map.values()].sort((a, b) => b.spend - a.spend).slice(0, 8);
};
const groupRoles = (events, pick) => {
const map = new Map();
for (const e of events) {
for (const sh of e.shifts || []) {
for (const r of sh.roles || []) {
const key = pick(r) || "Unknown";
const entry = map.get(key) || { name: key, spend: 0, count: 0 };
entry.spend += num(r.total_value);
entry.count += num(r.count);
map.set(key, entry);
}
}
}
return [...map.values()].sort((a, b) => b.spend - a.spend).slice(0, 8);
};
/* ============================================================ the workspace */
/**
* The Analytics page, as the object Owliver answers from.
*
* `metrics` and the series are taken straight from the page when it publishes
* them; everything else falls back to a derivation over the raw records.
*/
export function buildAnalyticsWorkspace(page = {}) {
const {
events = [], invoices = [], period = "all", groupBy = "monthly",
activeTab = "executive", userRole = "admin",
} = page || {};
const derived = page.metrics ? null : deriveFromRecords(events, invoices);
const m = page.metrics || derived;
const completed = events.filter((e) => e.status === "Completed").length;
const timeline = (page.spendTimeline || []).length
? page.spendTimeline.map((row) => ({
label: row.period, value: round(row.thisPeriod), hours: round(row.hours),
}))
: deriveTimeline(events, invoices);
const byHub = (page.byHub || []).length ? page.byHub : groupSpend(events, (e) => e.hub || "No Hub");
const byDept = (page.byDept || []).length ? page.byDept : groupRoles(events, (r) => r.department);
const byPosition = (page.byPosition || []).length ? page.byPosition : groupRoles(events, (r) => r.role);
return {
view: {
period, groupBy, activeTab, userRole,
periodLabel: PERIOD_LABEL[period] || String(period),
},
totals: {
/* Volume — what the page is reading. */
events: events.length,
completed,
open: events.length - completed,
invoices: invoices.length,
/* Money. */
revenue: round(m.totalSpend ?? m.spend),
spend: round(m.totalSpend ?? m.spend),
prevSpend: round(m.prevSpend),
spendDelta: m.spendDelta == null ? null : Number(m.spendDelta),
budget: round(m.budget),
budgetUsedPct: num(m.budgetUsedPct),
budgetLeft: round(m.budgetLeft ?? num(m.budget) - num(m.totalSpend ?? m.spend)),
laborCostPct: num(m.laborCostPct),
revenuePerLaborHour: num(m.revenuePerLaborHour),
/* Hours and coverage. */
scheduledHours: round(m.scheduledHours),
workedHours: round(m.effectiveWorkedHours ?? m.workedHours),
savedHours: round(m.savedHours),
hoursSavings: round(m.hoursSavings),
overtimeHours: round(m.overtimeHours),
fillRatePct: num(m.fillRatePct),
openShifts: round(m.openShifts),
noShows: round(m.noShows),
lateArrivals: round(m.lateArrivals),
},
timeline,
hoursTimeline: page.hoursTimeline || [],
byHub,
byDept,
byPosition,
};
}
export default buildAnalyticsWorkspace;

View File

@@ -0,0 +1,115 @@
/**
* Fallback knowledge — every page without a dedicated pack
* --------------------------------------------------------
* Owliver covers ~80 routes. Only the pages with a real analytical story get a
* hand-written pack; the rest keep the behaviour they already had — page-derived
* suggestions from the registry and answers from the local engine — and this
* adapter lifts that markdown into the same block schema the renderer uses.
*
* The adapter never invents structure: a lead sentence carrying a number becomes
* a metric, order lines become cards, links become actions, and anything it does
* not recognise stays as text. Degrading to prose is always safe.
*/
import { blocks, text, metric, cards, actions, status, plural } from "../blocks";
import { buildAnswerModel } from "../answerModel";
const SEVERITY = (card) => {
if (!card.requested) return { badge: "Unsized", tone: "neutral" };
const filled = card.assigned / card.requested;
if (filled >= 1) return { badge: "On track", tone: "positive" };
if (filled < 0.5) return { badge: "High risk", tone: "risk" };
return { badge: "Medium risk", tone: "warning" };
};
const toCard = (c) => {
const sev = SEVERITY(c);
return {
id: c.id,
title: c.business || c.name || "Record",
subtitle: c.name && c.name !== c.business ? c.name : null,
value: c.requested ? `${c.assigned}/${c.requested}` : null,
valueLabel: c.requested ? "staffed" : null,
badge: sev.badge,
badgeTone: sev.tone,
meta: [
c.hub && { label: "Hub", value: c.hub },
c.date && { label: "Date", value: c.time ? `${c.date} · ${c.time}` : c.date },
].filter(Boolean),
progress: c.requested
? { value: c.assigned, max: c.requested, label: c.gap > 0 ? `${plural(c.gap, "seat", "seats")} still open` : "Fully staffed", tone: sev.tone }
: null,
body: !c.requested && c.statusText ? c.statusText : null,
route: c.id ? `/EventDetail?id=${c.id}` : null,
actionLabel: c.id ? "View record" : null,
};
};
/** Legacy markdown answer → blocks. */
export function markdownToBlocks(markdown) {
const model = buildAnswerModel(markdown);
const { insight, cards: recs, paragraphs, actions: acts } = model;
const lead = [];
if (insight?.tone === "positive") {
lead.push(status({ tone: "positive", label: insight.label, text: insight.text }));
} else if (insight && insight.tone !== "prose" && insight.count != null) {
lead.push(
metric({
label: insight.label,
value: String(insight.count),
caption: insight.unit,
sub: insight.secondary ? [{ label: insight.secondary.unit, value: String(insight.secondary.value) }] : [],
}),
text(insight.text)
);
} else if (insight?.text) {
lead.push(text(insight.text));
}
return blocks(
lead,
recs.length ? cards(recs.map(toCard)) : null,
paragraphs.length ? text(paragraphs.join("\n\n")) : null,
acts.length ? actions(acts.map((a) => ({ label: a.label, route: a.route }))) : null
);
}
/**
* Build a pack for a route-derived domain. `domain` is the registry entry, so
* the title, placeholder, working line, insight and suggestions all stay the
* page's own.
*/
export const fallbackPack = (domain) => ({
key: domain.key,
title: domain.title,
placeholder: domain.placeholder || `Ask Owliver about ${String(domain.title || "this page").toLowerCase()}...`,
contextLabel: `Using ${domain.title} data`,
working: (domain.working || "Owliver is analyzing").replace(/[….]$/, ""),
contextFacts: (d) => {
if (!d) return [];
const MEASURE = /cost|amount|spend|rate|percent|pct|score|hours|avg|average|revenue/i;
return Object.entries(d)
.filter(([k, v]) => typeof v === "number" && Number.isInteger(v) && v > 0 && !MEASURE.test(k))
.slice(0, 3)
.map(([k, v]) => ({ value: v, label: k.replace(/([A-Z])/g, " $1").replace(/_/g, " ").toLowerCase().trim() }));
},
read: (d) => {
const insight = d ? domain.buildInsight(d) : null;
if (!insight?.headline) return null;
return { tone: "neutral", headline: insight.headline, lines: insight.supporting ? [insight.supporting] : [] };
},
suggestions: (d) => (d ? (domain.buildSuggestions(d) || []).filter(Boolean) : []),
/** Async: the local engine is loaded on demand, exactly as it was before. */
async answer({ prompt, pageContext, conversation }) {
const { getOwliverResponse } = await import("../owliverEngine");
const markdown = await getOwliverResponse({ prompt, pageContext, conversation, skipLatency: true });
return { topic: null, stages: [], blocks: markdownToBlocks(markdown), followUps: [] };
},
});
export default fallbackPack;

View File

@@ -0,0 +1,45 @@
/**
* Page-aware knowledge registry
* -----------------------------
*
* Active page → domain key → knowledge pack → suggestions / read / answers
*
* A pack is the whole contract between a page and Owliver:
*
* title, placeholder, contextLabel, working
* contextFacts(signals) → [{ value, label, tone }]
* read(signals) → { tone, headline, lines[], stat } (the current read)
* suggestions(signals) → [{ label, prompt }]
* answer({ prompt, signals, conversation, lastTopic, pageContext })
* → { topic, stages[], blocks[], followUps[] }
*
* Packs are keyed by the domain key the page registry already resolves, so a
* page gets its pack simply by being on a route that maps to that domain.
* Anything without a pack falls back to the route's registry entry, which is
* still page-specific — there is no shared default suggestion list anywhere.
*/
import savingEngine from "./savingEngine";
import orders from "./orders";
import analytics from "./analytics";
import { fallbackPack } from "./fallback";
export const KNOWLEDGE_PACKS = {
savingEngine,
orders,
analytics,
};
/**
* @param {string} domainKey resolved domain for the active page
* @param {object} domain the registry entry, used to build a fallback
*/
export function getKnowledge(domainKey, domain) {
const pack = KNOWLEDGE_PACKS[domainKey];
if (pack) return pack;
return fallbackPack(domain || { key: domainKey, title: "this workspace", buildInsight: () => null, buildSuggestions: () => [] });
}
export const hasPack = (domainKey) => !!KNOWLEDGE_PACKS[domainKey];
export default getKnowledge;

View File

@@ -0,0 +1,369 @@
/**
* Order Management — page knowledge
* ---------------------------------
* Unlike the Saving Engine pack, nothing here is demo data: `signals` is the
* live summary Events.jsx registers through useOwliverPageContext, normalised
* by PAGE_CONTEXTS.orders. The pack's job is only to turn that summary into
* blocks — which is exactly the job it will do when the Saving Engine's demo
* payload is replaced by a real one.
*/
import {
blocks, text, metric, cards, table, actions, list, status, plural,
} from "../blocks";
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/**
* "08.26.2026" → "Aug 26". The registry emits the dotted form for the engine's
* markdown lines; in a card the year is noise — these are operational dates.
*/
const shortDate = (raw) => {
const m = String(raw || "").match(/^(\d{1,2})\.(\d{1,2})\.\d{4}$/);
if (!m) return raw || null;
const month = MONTHS[Number(m[1]) - 1];
return month ? `${month} ${Number(m[2])}` : raw;
};
const SEVERITY = (card) => {
if (!card.requested) return { badge: "Unsized", tone: "neutral" };
const filled = card.assigned / card.requested;
if (filled >= 1) return { badge: "On track", tone: "positive" };
if (filled < 0.5) return { badge: "High risk", tone: "risk" };
return { badge: "Medium risk", tone: "warning" };
};
/** An order record → a panel card. */
const orderCard = (o) => {
const sev = SEVERITY(o);
return {
id: o.id,
title: o.business || o.name || "Order",
subtitle: o.name && o.name !== o.business ? o.name : null,
value: o.requested ? `${o.assigned}/${o.requested}` : null,
valueLabel: o.requested ? "staffed" : null,
badge: sev.badge,
badgeTone: sev.tone,
meta: [
o.hub && { label: "Hub", value: o.hub },
o.date && { label: "Date", value: shortDate(o.date) },
].filter(Boolean),
progress: o.requested
? {
value: o.assigned,
max: o.requested,
label: o.gap > 0 ? `${plural(o.gap, "seat", "seats")} still open` : "Fully staffed",
tone: sev.tone,
}
: null,
route: o.id ? `/EventDetail?id=${o.id}` : "/Events",
actionLabel: "View order",
};
};
const listOf = (d, key) => (Array.isArray(d[key]) ? d[key] : []);
/* ---------------------------------------------------------------- answers */
const answerAttention = (d) => {
const gaps = listOf(d, "gapsOrders").slice(0, 3);
if (!gaps.length) {
return {
topic: "attention",
stages: [`Reading ${d.total} orders…`],
blocks: blocks(
status({ tone: "positive", label: "No staffing gaps", text: "Every open order on this page is fully staffed." })
),
followUps: [
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
{ label: "Show today's critical orders", prompt: "Show today's critical orders" },
],
};
}
return {
topic: "attention",
stages: [`Reading ${d.total} orders…`, "Ranking by open headcount…"],
blocks: blocks(
text(`**${plural(gaps.length, "order needs", "orders need")} staffing attention**, ranked by how short they are.`),
metric({
label: "Open headcount",
value: String(d.openSeats),
caption: `seats open across ${plural(d.total, "order", "orders")}`,
sub: [
{ label: "Partially staffed", value: String(d.partial) },
{ label: "Upcoming at risk", value: String(d.atRisk) },
],
}),
cards(gaps.map(orderCard)),
actions([
{ label: "Open Order Management", route: "/Events" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
])
),
followUps: [
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
],
};
};
const answerRisk = (d) => {
const atRisk = listOf(d, "atRiskOrders").slice(0, 3);
if (!atRisk.length) {
return {
topic: "risk",
stages: ["Checking upcoming orders…"],
blocks: blocks(
status({ tone: "positive", label: "No immediate risk", text: "Nothing upcoming is short of headcount." })
),
followUps: [{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" }],
};
}
return {
topic: "risk",
stages: ["Checking upcoming orders…"],
blocks: blocks(
text(`**${plural(d.atRisk, "upcoming order is", "upcoming orders are")}** not fully staffed yet.`),
cards(atRisk.map(orderCard)),
actions([{ label: "Open Order Management", route: "/Events" }])
),
followUps: [
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
],
};
};
const answerFirst = (d) => {
const first = d.neediest || d.soonestAtRisk || listOf(d, "gapsOrders")[0];
if (!first) return null;
return {
topic: "first",
stages: ["Ranking by urgency…"],
blocks: blocks(
text(`**${first.business}** needs people first — ${plural(first.gap, "seat", "seats")} open, ${shortDate(first.date) || "date unset"}.`),
cards([orderCard(first)]),
actions([{ label: "Open this order", route: first.id ? `/EventDetail?id=${first.id}` : "/Events" }])
),
followUps: [
{ label: "Show partially staffed orders", prompt: "Show partially staffed orders" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
],
};
};
const answerPartial = (d) => {
const partial = listOf(d, "partialOrders");
if (!partial.length) {
return {
topic: "partial",
stages: ["Checking fill rates…"],
blocks: blocks(status({ tone: "positive", label: "No partial orders", text: "Nothing on this page is part-filled." })),
followUps: [{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" }],
};
}
return {
topic: "partial",
stages: ["Checking fill rates…"],
blocks: blocks(
text(`${plural(partial.length, "order is", "orders are")} partially staffed.`),
table({
title: "Partially staffed",
columns: [
{ key: "business", label: "Order", align: "left" },
{ key: "gap", label: "Open", align: "right", sortable: true, numeric: true },
{ key: "date", label: "Date", align: "left" },
],
rows: partial.slice(0, 8).map((o) => ({
id: o.id,
business: o.business,
gap: o.gap,
gapDisplay: String(o.gap),
date: shortDate(o.date) || "—",
route: o.id ? `/EventDetail?id=${o.id}` : "/Events",
detail: `${o.assigned} of ${o.requested} staffed${o.hub ? ` · ${o.hub}` : ""}`,
})),
footnote: "Tap a row for detail",
})
),
followUps: [
{ label: "Which one needs people first?", prompt: "Which one needs people first?" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
],
};
};
const answerPending = (d) => {
const pending = listOf(d, "pendingOrders");
return {
topic: "pending",
stages: ["Checking confirmations…"],
blocks: pending.length
? blocks(
text(`${plural(pending.length, "order is", "orders are")} still pending confirmation.`),
cards(pending.slice(0, 3).map(orderCard))
)
: blocks(status({ tone: "positive", label: "Nothing pending", text: "Every order on this page is confirmed." })),
followUps: [
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
],
};
};
const answerToday = (d) => {
const upcoming = listOf(d, "upcomingOrders").slice(0, 3);
return {
topic: "today",
stages: ["Reading the next few days…"],
blocks: upcoming.length
? blocks(text(`The ${upcoming.length} soonest orders on this page:`), cards(upcoming.map(orderCard)))
: blocks(status({ tone: "neutral", label: "Nothing upcoming", text: "No future-dated orders on this page." })),
followUps: [
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
{ label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
],
};
};
const answerSummary = (d) => ({
topic: "summary",
stages: [`Reading ${d.total} orders…`],
blocks: blocks(
text(`${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.active} active, ${d.draft} in draft.`),
metric({
label: "Staffed",
value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}`,
caption: "seats filled across open orders",
sub: [{ label: "Open seats", value: String(d.openSeats) }, { label: "At risk", value: String(d.atRisk) }],
}),
list({
title: "Breakdown",
items: [
{ label: "Fully staffed", value: String(d.full), tone: "positive" },
{ label: "Partially staffed", value: String(d.partial), tone: d.partial ? "risk" : "neutral" },
{ label: "Pending confirmation", value: String(d.requested) },
{ label: "Drafts", value: String(d.draft) },
],
}),
actions([{ label: "Open Order Management", route: "/Events" }])
),
followUps: [
{ label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
{ label: "Show upcoming risks", prompt: "Show upcoming risks" },
],
});
const ROUTES = [
[/\bfirst|most urgent|top priority|needs people first\b/i, answerFirst],
[/\bpartial|part.?filled|not fully staffed\b/i, answerPartial],
[/\bpending|unconfirmed|awaiting\b/i, answerPending],
[/\btoday|critical|next few days|soonest|upcoming\b/i, answerToday],
[/\brisk|at risk|unstaffed|going wrong\b/i, answerRisk],
[/\bstaffing|coverage|short|gap|headcount|attention|need\b/i, answerAttention],
[/\bsummar|overview|recap|how (are|is) things|status\b/i, answerSummary],
];
/**
* What a pronoun means, given what Owliver just said.
*
* The whole of conversational context on this page: "which one is the biggest?"
* after a summary is a question about the orders that summary counted, so it
* resolves inside the same page and the same records rather than starting over
* as a new question.
*/
const FROM_TOPIC = {
summary: [
[/\bbiggest|worst|first|most\b/i, answerFirst],
[/\brisk|at risk\b/i, answerRisk],
[/\bstaffing|short|gap|attention\b/i, answerAttention],
[/\bpending|unconfirmed\b/i, answerPending],
],
attention: [[/\bfirst|which one|worst|biggest\b/i, answerFirst], [/\brisk\b/i, answerRisk]],
risk: [[/\bfirst|which one|worst|biggest\b/i, answerFirst]],
partial: [[/\bfirst|which one|biggest\b/i, answerFirst]],
first: [[/\bothers?|rest|else|more\b/i, answerAttention], [/\brisk\b/i, answerRisk]],
pending: [[/\bfirst|which one|biggest\b/i, answerFirst]],
today: [[/\bfirst|which one|worst|biggest\b/i, answerFirst], [/\brisk\b/i, answerRisk]],
};
const answerUnknown = (d) => ({
topic: null,
stages: ["Reading this page…"],
blocks: blocks(
text(`I can analyse the ${plural(d.total, "order", "orders")} in this workspace, but I couldn't map that question onto them.`),
actions([
{ label: "Staffing", prompt: "Which orders need staffing attention?" },
{ label: "Risks", prompt: "Show upcoming risks" },
{ label: "Pending", prompt: "Which orders are still pending confirmation?" },
{ label: "Summary", prompt: "Summarise these orders" },
], { title: "Try asking about" })
),
followUps: [],
});
export const orders = {
key: "orders",
title: "Order Management",
placeholder: "Ask Owliver about orders, staffing, or risks...",
contextLabel: "Using Order Management data",
working: "Reading your orders",
contextFacts: (d) => [
{ value: d.total, label: d.total === 1 ? "order" : "orders" },
{ value: d.upcoming, label: "upcoming" },
{ value: d.partial, label: d.partial === 1 ? "staffing risk" : "staffing risks", tone: "risk" },
].filter((f) => Number(f.value) > 0),
read: (d) => {
if (d.partial > 0 || d.atRisk > 0) {
return {
tone: "risk",
headline: `${plural(Math.max(d.atRisk, d.partial), "order needs", "orders need")} staffing attention.`,
lines: [
`**${plural(d.openSeats, "open seat", "open seats")}** remain across the highest-risk orders.`,
`Reading ${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.draft} in draft.`,
],
stat: { label: "Staffed", value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}` },
};
}
return {
tone: "positive",
headline: "All orders are staffed and on track.",
lines: [`Reading ${plural(d.total, "order", "orders")} on this page — ${d.upcoming} upcoming, ${d.draft} in draft.`],
stat: { label: "Staffed", value: `${d.totalAssignedSeats}/${d.totalRequestedSeats}` },
};
},
suggestions: (d) => [
(d.partial > 0 || d.openSeats > 0) && { label: "Which orders need staffing?", prompt: "Which orders need staffing attention?" },
d.atRisk > 0 && { label: "Show upcoming risks", prompt: "Show upcoming risks" },
d.requested > 0 && { label: "What is still pending?", prompt: "Which orders are still pending confirmation?" },
d.partial > 0 && { label: "Show partially staffed orders", prompt: "Show partially staffed orders" },
{ label: "Show today's critical orders", prompt: "Show today's critical orders" },
{ label: "Summarise these orders", prompt: "Summarise these orders" },
].filter(Boolean).slice(0, 5),
answer({ prompt, signals, lastTopic }) {
const d = signals || {};
const q = String(prompt || "");
if (/\b(one|ones|these|those|them|it|that|this)\b/i.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
if (hit) {
const answered = hit[1](d);
if (answered) return answered;
}
}
const route = ROUTES.find(([pattern]) => pattern.test(q));
if (route) {
const answered = route[1](d);
if (answered) return answered;
}
return answerUnknown(d);
},
};
export default orders;

View File

@@ -0,0 +1,881 @@
/**
* Saving Engine — page knowledge
* ------------------------------
* What Owliver knows about the Saving Engine workspace: the read it produces,
* the questions that data makes worth asking, and the answers to them.
*
* Every answer below is a function of `signals` — the workspace the page
* published, derived in `savingsWorkspace.js` from the assignments, vendors,
* workforce, orders and rates the page had already loaded. Nothing in this file
* holds a figure of its own, and nothing reads a fixture: if the page's records
* change, every sentence here changes with them.
*
* That is also why each answer starts by checking whether the workspace has a
* basis for it. A page with no opportunities detected says so; it does not fall
* back to a number that was true of some other workspace.
*/
import {
blocks, text, note, metric, cards, table, chart, flow, actions, list,
progress, status, timeline, compare, money, pct, plural,
} from "../blocks";
/* ================================================================= helpers */
const IMPACT_TONE = { high: "risk", medium: "warning", low: "neutral" };
const IMPACT_LABEL = { high: "High impact", medium: "Medium impact", low: "Low impact" };
const SEVERITY_TONE = { high: "risk", medium: "warning", low: "neutral" };
/** An empty workspace still has the shape of one, so nothing has to null-check. */
const EMPTY = {
view: { activeTab: "overview", timeRange: "30days", timeLabel: "30-day", userRole: "admin" },
totals: {},
opportunities: [], risks: [], trend: [], byCategory: [], recentActivity: [], pipeline: [],
};
const workspace = (signals) => (signals && signals.totals ? signals : EMPTY);
/**
* Period-over-period movement, or null.
*
* Null when the dated records do not cover a prior period. Every caller checks,
* because "↑ 0.0%" against nothing is a claim the page cannot support.
*/
const trendPct = (d) => {
const { totalSavings, previousPeriodSavings } = d.totals;
if (!previousPeriodSavings) return null;
return ((Number(totalSavings || 0) - previousPeriodSavings) / previousPeriodSavings) * 100;
};
/** The headline metric, assembled from whatever the workspace can support. */
const savingsMetric = (d, label = "Savings potential") => {
const movement = trendPct(d);
return metric({
label,
value: money(d.totals.totalSavings),
trend: movement == null ? undefined : `${movement >= 0 ? "↑" : "↓"} ${pct(Math.abs(movement), 1)}`,
trendTone: movement == null ? "neutral" : movement >= 0 ? "positive" : "risk",
caption: movement == null
? `${d.view.timeLabel} projection`
: `${d.view.timeLabel} projection · vs previous month`,
sub: [
{ label: "Monthly", value: money(d.totals.monthlyPotential) },
{ label: "Annualized", value: money(d.totals.annualisedPotential) },
],
});
};
/** An opportunity record → a card the panel can render and open. */
const oppCard = (o) => ({
id: o.id,
title: o.title,
subtitle: o.category,
value: money(o.annual),
valueLabel: "per year",
badge: IMPACT_LABEL[o.impact],
badgeTone: IMPACT_TONE[o.impact],
meta: [
{ label: "Status", value: o.status },
{ label: "Effort", value: o.effort },
{ label: "Owner", value: o.owner },
],
progress: { value: o.share, max: 100, label: `${o.share}% of total opportunity` },
route: o.route,
actionLabel: "View",
});
const riskCard = (r) => ({
id: r.id,
title: r.title,
subtitle: r.dueInDays > 0 ? `Due in ${plural(r.dueInDays, "day", "days")}` : "Live now",
value: money(r.exposure),
valueLabel: "at risk",
badge: r.severity === "high" ? "High severity" : "Medium severity",
badgeTone: SEVERITY_TONE[r.severity],
meta: [{ label: "Exposure", value: `${money(r.exposure)}/yr` }],
body: r.detail,
route: r.route,
actionLabel: r.action,
});
const topOpportunities = (d, n = 3) => d.opportunities.slice(0, n);
/**
* The reply when the workspace cannot support a question at all.
*
* Says which records are missing rather than reporting a zero, because an empty
* Saving Engine and a Saving Engine with nothing to save look identical in a
* figure and are completely different things.
*/
const nothingToRead = (topic, what) => ({
topic,
stages: ["Reading Saving Engine data…"],
blocks: blocks(
text(`This workspace has no ${what} to read yet.`),
note("Owliver reads the assignments, vendors, rates and workforce records on the Saving Engine page. Once those carry data, this answer fills in on its own."),
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
),
followUps: [],
});
/* ================================================================ answers */
/**
* Each responder returns the blocks for one answer plus the follow-ups that
* answer makes sensible. Follow-ups are prompts — clicking one goes through the
* same `send()` path as typing it, so nothing here is a canned branch — and
* they are built from the records the answer just showed, so they always name
* something that exists in this workspace.
*/
const answerOverview = (d) => {
const t = d.totals;
if (!t.totalSpend) return nothingToRead("overview", "billed spend");
return {
topic: "overview",
stages: ["Reading Saving Engine data…", `Checking ${plural(t.assignments || 0, "assignment", "assignments")}…`],
blocks: blocks(
text(`The Saving Engine is tracking **${money(t.totalSpend)}** of billed spend, ${pct(t.contractedRatio, 1)} of it under contract. That leaves **${money(t.totalSavings)}** of savings potential on the ${d.view.timeLabel.toLowerCase()} view.`),
savingsMetric(d),
list({
title: "Workspace",
items: [
{ label: "Opportunities detected", value: String(t.opportunities) },
{ label: "Running without renegotiation", value: String(t.automations) },
{ label: "Spend under contract", value: money(t.contractedSpend), tone: "positive" },
{ label: "Spend outside contract", value: money(t.nonContractedSpend), tone: t.nonContractedSpend ? "risk" : "neutral" },
t.atRisk ? { label: "Savings at risk", value: money(t.atRisk), tone: "risk" } : null,
].filter(Boolean),
}),
actions([
d.opportunities.length ? { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" } : null,
d.risks.length ? { label: "What savings are at risk?", prompt: "What savings are at risk?" } : null,
].filter(Boolean))
),
followUps: [
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
].filter(Boolean).slice(0, 3),
};
};
const answerWhereMost = (d) => {
if (!d.opportunities.length) return nothingToRead("where", "savings opportunities");
const top = d.opportunities[0];
const leader = d.byCategory[0];
return {
topic: "where",
stages: ["Reading Saving Engine data…", `Comparing ${plural(d.byCategory.length, "category", "categories")}…`],
blocks: blocks(
text(`Most savings are currently coming from **${top.title}**. It accounts for ${top.share}% of the total opportunity on this page, and ${leader.label} is the largest category overall.`),
savingsMetric(d),
progress({
title: "Savings by category",
items: d.byCategory.map((c) => ({
label: c.label,
value: c.value,
max: leader.value,
caption: money(c.value),
tone: c.label === leader.label ? "brand" : "neutral",
})),
}),
text(top.detail),
actions([
{ label: "Open Saving Engine", route: "/SavingsEngine" },
{ label: "Show biggest opportunities", prompt: "Show my biggest savings opportunities" },
])
),
followUps: [
{ label: `Why is ${top.title.toLowerCase()} #1?`, prompt: `Why is ${top.title} the number one saving?` },
{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
].filter(Boolean),
};
};
const answerOpportunities = (d) => {
if (!d.opportunities.length) return nothingToRead("opportunities", "savings opportunities");
const top = topOpportunities(d, 3);
const share = top.reduce((n, o) => n + o.share, 0);
const rest = d.opportunities.length - top.length;
return {
topic: "opportunities",
stages: [`Checking ${plural(d.totals.opportunities, "opportunity", "opportunities")}…`, "Ranking by annualised value…"],
blocks: blocks(
text(`I found **${plural(top.length, "opportunity", "opportunities")}** worth acting on out of ${d.totals.opportunities} detected. Together they carry ${share}% of the total potential.`),
cards(top.map(oppCard)),
rest > 0 ? note(`${plural(rest, "smaller opportunity is", "smaller opportunities are")} open behind these.`) : null,
actions([
rest > 0 ? { label: "Show all opportunities", prompt: "Show all high-value opportunities" } : null,
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
].filter(Boolean))
),
followUps: [
{ label: "Which one is easiest to implement?", prompt: "Which one is the easiest to implement?" },
top.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerEasiest = (d) => {
if (!d.opportunities.length) return nothingToRead("easiest", "savings opportunities");
const ranked = [...d.opportunities].sort(
(a, b) => a.weeksToValue - b.weeksToValue || b.annual - a.annual
);
const pick = ranked[0];
return {
topic: "easiest",
stages: ["Comparing effort and time-to-value…"],
blocks: blocks(
text(`**${pick.title}** is the easiest to act on — ${pick.effort} effort and ${plural(pick.weeksToValue, "week", "weeks")} to value.`),
metric({
label: pick.title,
value: money(pick.annual),
caption: `per year · ${pick.confidence}% confidence`,
sub: [
{ label: "Effort", value: pick.effort },
{ label: "Time to value", value: `${pick.weeksToValue} wks` },
],
}),
ranked.length > 1 ? compare({
title: "Effort vs value",
items: ranked.slice(0, 3).map((o) => ({
label: o.title,
value: money(o.annual, { compact: true }),
caption: `${o.weeksToValue} wks · ${o.effort} effort`,
tone: o.id === pick.id ? "positive" : "neutral",
})),
}) : null,
text(pick.detail),
pick.basis ? note(`Basis — ${pick.basis}`) : null,
actions([
{ label: `Open ${pick.owner}`, route: pick.route },
d.risks.length ? { label: "What savings are at risk?", prompt: "What savings are at risk?" } : null,
].filter(Boolean))
),
followUps: [
{ label: `What would ${pick.title.toLowerCase()} take?`, prompt: `What would it take to start ${pick.title}?` },
ranked.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerCompare = (d) => {
if (!d.opportunities.length) return nothingToRead("compare", "savings opportunities");
const top = topOpportunities(d, 4);
return {
topic: "compare",
stages: ["Building comparison…"],
blocks: blocks(
text("Side by side, ranked by annualised value:"),
table({
title: "Savings opportunities",
columns: [
{ key: "title", label: "Opportunity", align: "left" },
{ key: "annual", label: "Potential", align: "right", sortable: true, numeric: true },
{ key: "effort", label: "Effort", align: "left" },
{ key: "status", label: "Status", align: "left", badge: true },
],
rows: top.map((o) => ({
id: o.id,
title: o.title,
annual: o.annual,
annualDisplay: money(o.annual),
effort: o.effort,
status: o.status,
route: o.route,
detail: o.detail,
})),
footnote: "Tap a row for detail · sort by potential",
}),
actions([
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
{ label: "Open Saving Engine", route: "/SavingsEngine" },
])
),
followUps: [
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
].filter(Boolean),
};
};
const answerTable = (d) => {
if (!d.opportunities.length) return nothingToRead("table", "savings opportunities");
const rows = d.opportunities;
return {
topic: "table",
stages: [`Pulling ${plural(rows.length, "opportunity", "opportunities")}…`],
blocks: blocks(
text(`All ${plural(rows.length, "open opportunity", "open opportunities")}, highest value first.`),
table({
title: "Savings opportunities",
columns: [
{ key: "title", label: "Opportunity", align: "left" },
{ key: "annual", label: "Potential", align: "right", sortable: true, numeric: true },
{ key: "status", label: "Status", align: "left", badge: true },
],
rows: rows.map((o) => ({
id: o.id,
title: o.title,
annual: o.annual,
annualDisplay: money(o.annual),
status: o.status,
route: o.route,
detail: o.detail,
})),
footnote: "Tap a row for detail",
})
),
followUps: [
rows.length > 1 && { label: "Compare these opportunities", prompt: "Compare these opportunities" },
{ label: "Which is easiest to implement?", prompt: "Which one is the easiest to implement?" },
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
].filter(Boolean),
};
};
const answerRisk = (d) => {
if (!d.risks.length) {
return {
topic: "risk",
stages: ["Checking captured savings…", "Scanning renewals and reliability…"],
blocks: blocks(
text("Nothing in this workspace is currently at risk of being given back."),
status({
tone: "positive",
label: "No exposure",
text: "No vendor certificate or contracted rate expires inside the next 60 days, and no worker holding live assignments is below the reliability threshold.",
}),
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
),
followUps: [
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
].filter(Boolean),
};
}
const first = d.risks[0];
return {
topic: "risk",
stages: ["Checking captured savings…", "Scanning renewals and reliability…"],
blocks: blocks(
text(`**${money(d.totals.atRisk)}** of savings is at risk across ${plural(d.risks.length, "item", "items")}. ${first.title} is the largest.`),
metric({
label: "At-risk savings",
value: money(d.totals.atRisk),
trend: plural(d.risks.length, "item", "items"),
trendTone: "risk",
caption: "against savings the workspace already holds",
}),
cards(d.risks.map(riskCard)),
actions([
{ label: first.action, route: first.route },
{ label: "Open Saving Engine", route: "/SavingsEngine" },
])
),
followUps: [
/* The title is a record name — a vendor, a rate book — so its case is
left exactly as the record carries it. */
{ label: `What if ${first.title} is not addressed?`, prompt: `What happens if ${first.title} is not addressed?` },
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.recentActivity.length && { label: "Show recent saving activity", prompt: "Show recent saving activity" },
].filter(Boolean),
};
};
const answerRenewal = (d) => {
if (!d.risks.length) return answerRisk(d);
const r = d.risks[0];
return {
topic: "renewal",
stages: ["Modelling the exposure…"],
blocks: blocks(
text(`If **${r.title}** is left unaddressed, the work behind it reverts to spot pricing at the next billing cycle.`),
metric({
label: "Exposure if unaddressed",
value: money(r.exposure),
trend: r.dueInDays > 0 ? `${plural(r.dueInDays, "day", "days")} left` : "live now",
trendTone: "risk",
caption: "annualised give-back",
}),
text(r.detail),
list({
title: "What closes it",
items: [
{ label: r.action, value: "Owner action", tone: "neutral" },
{ label: "Confirm before", value: r.dueInDays > 0 ? plural(r.dueInDays, "day", "days") : "today", tone: "risk" },
],
}),
actions([{ label: r.action, route: r.route }])
),
followUps: [
{ label: "What savings are at risk?", prompt: "What savings are at risk?" },
d.recentActivity.length && { label: "Show recent saving activity", prompt: "Show recent saving activity" },
].filter(Boolean),
};
};
const answerTrend = (d) => {
if (d.trend.length < 2) {
return {
topic: "trend",
stages: ["Reading dated assignments…"],
blocks: blocks(
text("There is not enough dated history in this workspace to read a trend yet."),
note(d.trend.length
? "Only one month of assignments carries a scheduled date, so there is nothing to compare it against."
: "No assignment on this page carries a scheduled date, so no monthly series can be built."),
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
),
followUps: [
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
].filter(Boolean),
};
}
const first = d.trend[0];
const last = d.trend[d.trend.length - 1];
const prior = d.trend[d.trend.length - 2];
const growth = first.value ? ((last.value - first.value) / first.value) * 100 : null;
const mom = prior.value ? ((last.value - prior.value) / prior.value) * 100 : null;
const rising = last.value >= first.value;
return {
topic: "trend",
stages: [`Reading ${plural(d.trend.length, "month", "months")} of assignments…`, "Fitting the trend…"],
blocks: blocks(
text(`Savings run-rate has ${rising ? "risen" : "fallen"} across the period — ${money(first.value, { compact: true })} in ${first.label} to ${money(last.value, { compact: true })} in ${last.label}.`),
chart({
title: "Monthly savings run-rate",
variant: "area",
data: d.trend,
format: "money",
summary: growth == null ? `${d.trend.length} months tracked` : `${growth >= 0 ? "↑" : "↓"} ${pct(Math.abs(growth), 1)} over ${plural(d.trend.length, "month", "months")}`,
}),
metric({
label: "Month over month",
value: mom == null ? "—" : `${mom >= 0 ? "+" : "−"}${pct(Math.abs(mom), 1)}`,
caption: `${last.label} vs ${prior.label}`,
sub: [
{ label: "This month", value: money(last.value) },
{ label: "Period total", value: money(d.trend.reduce((n, t) => n + t.value, 0)) },
],
}),
note("Run-rate is the spend outside a contracted rate in each month, at the rate spread this workspace is currently carrying."),
actions([
{ label: "Show biggest contributors", prompt: "Show the biggest contributors to the savings trend" },
{ label: "Open Saving Engine", route: "/SavingsEngine" },
])
),
followUps: [
{ label: "What caused the change?", prompt: "What caused the change in savings?" },
{ label: "Show biggest contributors", prompt: "Show the biggest contributors to the savings trend" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerContributors = (d) => {
if (!d.opportunities.length) return nothingToRead("contributors", "savings opportunities");
const total = d.opportunities.reduce((n, o) => n + o.annual, 0);
const lead = d.opportunities[0];
return {
topic: "contributors",
stages: ["Attributing the movement…"],
blocks: blocks(
text(`The potential is concentrated in ${plural(Math.min(d.opportunities.length, 4), "place", "places")}:`),
progress({
title: "Contribution to potential",
items: d.opportunities.slice(0, 4).map((o) => ({
label: o.title,
value: o.share,
max: 100,
caption: `${o.share}% · ${money(o.annual, { compact: true })}`,
tone: o.id === lead.id ? "brand" : "neutral",
})),
}),
text(`${lead.title} carries ${lead.share}% of the ${money(total)} total on its own. ${lead.detail}`),
actions([{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }])
),
followUps: [
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerHowItWorks = (d) => {
if (!d.pipeline.length) return nothingToRead("how", "workspace data");
return {
topic: "how",
stages: ["Tracing the savings pipeline…"],
blocks: blocks(
text("The Saving Engine runs a pipeline over the spend records connected to this workspace. Each stage narrows raw spend down to a captured saving:"),
flow({ title: "Savings pipeline", nodes: d.pipeline }),
note("Tap any stage for what it is doing on this page right now."),
actions([
d.opportunities.length ? { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" } : null,
{ label: "Open Saving Engine", route: "/SavingsEngine" },
].filter(Boolean))
),
followUps: [
{ label: "What is waiting on approval?", prompt: "What is waiting on approval?" },
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerApprovals = (d) => {
const waiting = d.opportunities.filter((o) => o.annual >= 10000);
if (!waiting.length) {
return {
topic: "approvals",
stages: ["Checking the approval queue…"],
blocks: blocks(
text("Nothing in this workspace needs finance sign-off."),
note("Anything above $10K annualised needs approval before it can run unattended. No detected opportunity clears that line."),
actions(d.opportunities.length
? [{ label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" }] : [])
),
followUps: [{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" }],
};
}
return {
topic: "approvals",
stages: ["Checking the approval queue…"],
blocks: blocks(
text(`${plural(waiting.length, "recommendation is", "recommendations are")} above the $10K annualised line and ${waiting.length === 1 ? "needs" : "need"} finance sign-off before running unattended.`),
list({
title: "Awaiting approval",
items: waiting.map((o) => ({
label: o.title,
value: `${money(o.annual)} · ${o.owner}`,
tone: o.impact === "high" ? "risk" : "neutral",
})),
}),
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
),
followUps: [
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
].filter(Boolean),
};
};
const answerAutomations = (d) => {
const automatable = d.opportunities.filter((o) => o.effort === "low");
const negotiated = d.opportunities.length - automatable.length;
if (!d.opportunities.length) return nothingToRead("automations", "savings rules");
return {
topic: "automations",
stages: ["Checking automation state…"],
blocks: blocks(
text(`${plural(automatable.length, "opportunity", "opportunities")} can run without renegotiation; the other ${negotiated} need${negotiated === 1 ? "s" : ""} a commercial conversation first.`),
status({
tone: automatable.length ? "positive" : "warning",
label: automatable.length ? `${automatable.length} ready to automate` : "None ready",
text: automatable.length
? `${automatable.map((o) => o.title).join(", ")} rest on rates that already exist, so they can be enforced as standing rules.`
: "Every detected opportunity needs a rate or contract change before it can be enforced automatically.",
}),
list({
title: "Automation health",
items: [
{ label: "Runs unattended", value: String(automatable.length), tone: automatable.length ? "positive" : "neutral" },
{ label: "Needs negotiation", value: String(negotiated), tone: negotiated ? "risk" : "neutral" },
{ label: "Detections active", value: String(d.totals.activeRules) },
],
}),
actions([{ label: "Open vendor rates", route: "/VendorRates" }])
),
followUps: [
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
].filter(Boolean),
};
};
const answerActivity = (d) => {
if (!d.recentActivity.length) return nothingToRead("activity", "recent activity");
return {
topic: "activity",
stages: [`Reading ${plural(d.recentActivity.length, "recent event", "recent events")}…`],
blocks: blocks(
text(`The ${plural(d.recentActivity.length, "most recent change", "most recent changes")} in the records this page reads:`),
timeline({ title: "Recent activity", items: d.recentActivity }),
actions([{ label: "Open activity log", route: "/ActivityLog" }])
),
followUps: [
d.risks.length && { label: "What savings are at risk?", prompt: "What savings are at risk?" },
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
].filter(Boolean),
};
};
const answerNumbers = (d) => {
const t = d.totals;
if (!t.totalSpend) return nothingToRead("numbers", "billed spend");
return {
topic: "numbers",
stages: ["Pulling the headline figures…"],
blocks: blocks(
text("The figures this page is currently carrying:"),
savingsMetric(d),
table({
title: "Saving Engine metrics",
columns: [
{ key: "label", label: "Metric", align: "left" },
{ key: "value", label: "Value", align: "right", sortable: false },
],
rows: [
{ id: "spend", label: "Total billed spend", value: money(t.totalSpend) },
{ id: "contracted", label: "Under contract", value: `${money(t.contractedSpend)} (${pct(t.contractedRatio, 1)})` },
{ id: "spot", label: "Outside contract", value: money(t.nonContractedSpend) },
{ id: "rate", label: "Rate spread", value: `$${Number(t.rateDelta || 0).toFixed(2)}/hr` },
{ id: "fill", label: "Fill rate", value: pct(t.fillRate, 1) },
{ id: "reliability", label: "Avg reliability", value: pct(t.avgReliability, 0) },
{ id: "noshow", label: "No-show rate", value: pct(t.noShowRate, 1) },
{ id: "vendors", label: "Active vendors", value: String(t.activeVendors) },
{ id: "workforce", label: "Workforce", value: String(t.totalWorkforce) },
],
}),
actions([{ label: "Open Saving Engine", route: "/SavingsEngine" }])
),
followUps: [
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
].filter(Boolean),
};
};
/* ============================================================== dispatcher */
/**
* Keyword routing. Deliberately shallow — enough to prove the interaction, and
* ordered so the specific patterns win over the broad ones. Follow-ups are
* resolved first, against `lastTopic`, so "which one is easiest?" is understood
* as being about the cards Owliver just showed.
*/
const ROUTES = [
[/\beasiest|simplest|quickest|least effort|low effort|fastest to\b/i, answerEasiest],
[/\bcompare|side by side|versus|vs\b/i, answerCompare],
[/\ball (the )?(high.?value|opportunities)|show all|full list|every opportunity|table\b/i, answerTable],
[/\bcaused|why (did|is|has).*(increase|rise|grow|change)|contributor|driving\b/i, answerContributors],
[/\brenewal|lapse|expire|not addressed|unaddressed|what happens if\b/i, answerRenewal],
[/\bapprov|sign.?off\b/i, answerApprovals],
[/\bautomation|rules? running|paused|unattended\b/i, answerAutomations],
[/\bhow does|how it works|how the .*works?|explain the process|pipeline|workflow\b/i, answerHowItWorks],
[/\btrend|over time|last (six|6) months|month over month|growth\b/i, answerTrend],
[/\brisk|at risk|exposure|losing|give back|lapsing\b/i, answerRisk],
[/\b(recent )?activity|what happened|latest|log\b/i, answerActivity],
[/\bimportant numbers|key numbers|metrics|figures|numbers\b/i, answerNumbers],
[/\bopportunit|biggest|top saving|where can (we|i) save|potential\b/i, answerOpportunities],
[/\bwhere .*(saving|save)|most saving|saving the most|which category|breakdown\b/i, answerWhereMost],
[/\bhow much|total saving|overview|summary|summar|status\b/i, answerOverview],
];
/** Pronoun follow-ups ("which one…", "compare these") resolved against topic. */
const FOLLOW_UP = /\b(one|ones|these|those|them|it|that|this)\b/i;
/**
* What a pronoun means, given what Owliver just said.
*
* This is the whole of conversational context on this page: the previous
* answer's topic decides which reading "the biggest" refers to, so a follow-up
* stays inside the same page and the same set of records it was asked about.
*/
const FROM_TOPIC = {
overview: [
[/\bbiggest|largest|most\b/i, answerOpportunities],
[/\brisk\b/i, answerRisk],
[/\bbreak ?down|categor\b/i, answerWhereMost],
],
opportunities: [
[/\beasiest|simplest|quickest|least effort\b/i, answerEasiest],
[/\bcompare|rank|side by side\b/i, answerCompare],
[/\bbiggest|largest|worth most\b/i, answerCompare],
[/\bnext|more|others?|all\b/i, answerTable],
[/\brisk\b/i, answerRisk],
],
where: [
[/\bwhy\b/i, answerContributors],
[/\bbiggest|largest\b/i, answerOpportunities],
],
easiest: [
[/\bstart|begin|take|do it|what would it\b/i, answerEasiest],
[/\bcompare\b/i, answerCompare],
[/\bbiggest|largest\b/i, answerOpportunities],
],
compare: [
[/\beasiest|simplest\b/i, answerEasiest],
[/\bbiggest|largest|first\b/i, answerOpportunities],
],
table: [
[/\beasiest|simplest\b/i, answerEasiest],
[/\bbiggest|largest\b/i, answerOpportunities],
],
trend: [
[/\bcaused|why|driver|contributor\b/i, answerContributors],
[/\bcompare|last month\b/i, answerContributors],
],
contributors: [
[/\bbiggest|largest|first\b/i, answerOpportunities],
[/\btrend\b/i, answerTrend],
],
risk: [
[/\bwhat happens|if|unaddressed\b/i, answerRenewal],
[/\bfirst|worst|biggest\b/i, answerRenewal],
],
renewal: [
[/\bother|rest|else\b/i, answerRisk],
],
how: [
[/\bapprov|waiting\b/i, answerApprovals],
[/\bautomat\b/i, answerAutomations],
],
numbers: [
[/\bbiggest|largest\b/i, answerOpportunities],
[/\bbreak ?down|categor\b/i, answerWhereMost],
],
};
/** The dead end, which still has to be useful and still page-bound. */
const answerUnknown = (d) => ({
topic: null,
stages: ["Reading Saving Engine data…"],
blocks: blocks(
text("I can analyse anything in the current Saving Engine workspace, but I couldn't map that question onto it."),
actions([
{ label: "Summary", prompt: "Give me a summary" },
d.opportunities.length ? { label: "Opportunities", prompt: "Show my biggest savings opportunities" } : null,
d.risks.length ? { label: "Risks", prompt: "What savings are at risk?" } : null,
d.trend.length > 1 ? { label: "Trends", prompt: "Explain the savings trend" } : null,
{ label: "How it works", prompt: "How does the Saving Engine work?" },
].filter(Boolean), { title: "Try asking about" })
),
followUps: [],
});
/* ================================================================== export */
export const savingEngine = {
key: "savingEngine",
title: "Saving Engine",
entity: "SavingsOpportunity",
placeholder: "Ask Owliver about Saving Engine...",
contextLabel: "Using Saving Engine data",
working: "Reading Saving Engine data",
contextFacts: (signals) => {
const d = workspace(signals);
return [
{ value: money(d.totals.totalSavings, { compact: true }), label: "potential" },
{ value: d.totals.opportunities || 0, label: "opportunities" },
d.totals.atRisk ? { value: money(d.totals.atRisk, { compact: true }), label: "at risk", tone: "risk" } : null,
].filter(Boolean);
},
/** The dynamic "current read" — short lines, all from this page's records. */
read: (signals) => {
const d = workspace(signals);
const t = d.totals;
if (!t.totalSpend) {
return {
tone: "neutral",
headline: "No billed spend on this page yet.",
lines: ["Owliver reads the assignments, vendors, rates and workforce this page has loaded."],
stat: null,
};
}
const lead = d.opportunities[0];
return {
tone: t.atRisk ? "warning" : "positive",
headline: lead
? `${lead.title} is the largest opportunity on this page.`
: `${pct(t.contractedRatio, 1)} of billed spend is already under contract.`,
lines: [
`**${money(t.monthlyPotential, { compact: true })}** in monthly savings potential across ${plural(t.opportunities, "opportunity", "opportunities")}.`,
lead
? `**${lead.title} carries ${lead.share}%** of the total potential, at ${money(lead.annual)} a year.`
: `**${money(t.contractedSpend)}** of ${money(t.totalSpend)} runs at contracted rates.`,
t.atRisk ? `**${money(t.atRisk)}** is at risk across ${plural(d.risks.length, "item", "items")}.` : null,
].filter(Boolean),
stat: {
label: `${d.view.timeLabel} projection`,
value: money(t.totalSavings),
trend: trendPct(d) == null ? undefined : `↑ ${pct(Math.abs(trendPct(d)), 1)}`,
},
};
},
/**
* The questions this page's data makes worth asking.
*
* Derived, not listed: a chip is only offered when the workspace has the
* records to answer it, and the ones that name a record name a real one.
*/
suggestions: (signals) => {
const d = workspace(signals);
const lead = d.opportunities[0];
return [
{ label: "Give me a summary", prompt: "Give me a summary" },
d.byCategory.length > 1 && { label: "Where are we saving the most?", prompt: "Where are we saving the most?" },
d.opportunities.length && { label: "Show my biggest opportunities", prompt: "Show my biggest savings opportunities" },
d.risks.length && { label: `What savings are at risk?`, prompt: "What savings are at risk?" },
lead && { label: `Why is ${lead.title.toLowerCase()} #1?`, prompt: `Why is ${lead.title} the number one saving?` },
d.trend.length > 1 && { label: "Explain the savings trend", prompt: "Explain the savings trend" },
{ label: "How does Saving Engine work?", prompt: "How does the Saving Engine work?" },
{ label: "Show the important numbers", prompt: "Show the important numbers" },
].filter(Boolean);
},
answer({ prompt, signals, lastTopic }) {
const d = workspace(signals);
const q = String(prompt || "");
// A pronoun means the question is about the last answer, so that answer's
// own follow-up table gets first refusal.
if (FOLLOW_UP.test(q) && lastTopic && FROM_TOPIC[lastTopic]) {
const hit = FROM_TOPIC[lastTopic].find(([pattern]) => pattern.test(q));
if (hit) return hit[1](d);
}
const route = ROUTES.find(([pattern]) => pattern.test(q));
if (route) return route[1](d);
// "saving"/"save" with nothing more specific is the overview.
if (/\bsavings?|save|spend|cost\b/i.test(q)) return answerOverview(d);
return answerUnknown(d);
},
};
export default savingEngine;

View File

@@ -0,0 +1,673 @@
/**
* Saving Engine — workspace derivation
* ------------------------------------
* The Saving Engine page's own records, read into the shape Owliver answers
* from. One derivation, two callers:
*
* the page registers assignments, vendors, workforce, orders and rates
* through `useOwliverPageContext`, and Owliver reads what is
* already on screen
* the route when the panel is opened on a Saving Engine route the page has
* not mounted, `DOMAINS.savingEngine.loadSignals()` fetches the
* same entities and runs this same function
*
* That is the whole point of putting it here rather than in either caller: the
* figure Owliver quotes and the figure the card beside it draws come from one
* computation, so they cannot disagree.
*
* Every number below is traceable to a record. Where the page has no basis for
* something — no prior window to compare against, no rate book to check a bill
* rate against — the field is omitted rather than filled with a plausible
* value, and the answers read as "nothing to report" instead of inventing one.
*/
const num = (v) => Number(v || 0);
const round = (v) => Math.round(num(v));
const safeDiv = (a, b) => (num(b) ? num(a) / num(b) : 0);
const TIME_LABEL = {
"7days": "7-day", "30days": "30-day", quarter: "Quarterly", year: "Annual",
};
/* ------------------------------------------------------------------ dates */
const asDate = (value) => {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
};
const monthKey = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
const monthLabel = (d) => d.toLocaleDateString("en-US", { month: "short" });
const daysUntil = (value) => {
const d = asDate(value);
if (!d) return null;
return Math.ceil((d.getTime() - Date.now()) / 86400000);
};
const relativeTime = (value) => {
const d = asDate(value);
if (!d) return null;
const hours = Math.round((Date.now() - d.getTime()) / 3600000);
if (hours < 1) return "just now";
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days === 1) return "yesterday";
if (days < 0) return `in ${Math.abs(days)}d`;
return `${days}d ago`;
};
/* ---------------------------------------------------------------- metrics */
/**
* The page's own metric block, recomputed.
*
* Deliberately the same arithmetic as `SavingsEngine.jsx` — when the page hands
* its own `metrics` over, that object wins and this is never used. It exists so
* the route-level fallback produces the identical shape rather than a second,
* subtly different reading of the same records.
*/
export function deriveMetrics({ assignments = [], vendors = [], workforce = [], orders = [], rates = [] }) {
const totalSpend = assignments.reduce((n, a) => n + num(a.total_bill), 0);
const contractedSpend = assignments
.filter((a) => a.vendor_id)
.reduce((n, a) => n + num(a.total_bill), 0);
const nonContractedSpend = totalSpend - contractedSpend;
const contractedRatio = totalSpend > 0 ? (contractedSpend / totalSpend) * 100 : 0;
const approved = rates.filter((r) => r.is_approved_rate);
const unapproved = rates.filter((r) => !r.is_approved_rate);
const avgContractedRate = safeDiv(
approved.reduce((n, r) => n + num(r.client_rate), 0), Math.max(approved.length, 1)
);
const avgNonContractedRate = safeDiv(
unapproved.reduce((n, r) => n + num(r.client_rate), 0), Math.max(unapproved.length, 1)
);
const potentialSavingsPercent = avgNonContractedRate > avgContractedRate
? ((avgNonContractedRate - avgContractedRate) / avgNonContractedRate) * 100
: 15;
const potentialSavings = nonContractedSpend * (potentialSavingsPercent / 100);
const avgReliability = safeDiv(
workforce.reduce((n, w) => n + num(w.reliability_index ?? 75), 0), Math.max(workforce.length, 1)
);
const noShowRate = safeDiv(
workforce.reduce((n, w) => n + num(w.no_shows), 0),
Math.max(workforce.reduce((n, w) => n + num(w.total_assignments || 1), 0), 1)
) * 100;
const fillRate = safeDiv(
assignments.filter((a) => a.assignment_status === "Completed").length,
Math.max(assignments.length, 1)
) * 100;
return {
totalSpend, contractedSpend, nonContractedSpend, contractedRatio,
potentialSavings, potentialSavingsPercent,
avgContractedRate: avgContractedRate || 45,
avgNonContractedRate: avgNonContractedRate || 55,
avgReliability, noShowRate, fillRate,
activeVendors: vendors.filter((v) => v.is_active).length,
totalWorkforce: workforce.length,
completedOrders: orders.filter((o) => o.order_status === "Completed").length,
};
}
/** The page's projection model, recomputed on the same terms. */
export const deriveProjections = (metrics) => {
const daily = safeDiv(metrics.potentialSavings, 30);
return {
sevenDays: daily * 7,
thirtyDays: num(metrics.potentialSavings),
quarter: num(metrics.potentialSavings) * 3,
year: num(metrics.potentialSavings) * 12,
};
};
const projectedFor = (projections, timeRange) => {
switch (timeRange) {
case "7days": return num(projections.sevenDays);
case "quarter": return num(projections.quarter);
case "year": return num(projections.year);
default: return num(projections.thirtyDays);
}
};
/* ---------------------------------------------------------- rate evidence */
/**
* The approved bill rate per role, from the rate book the workspace actually
* carries. An assignment billed above it is the evidence behind rate drift, and
* a role the rate book does not cover is left out rather than assumed.
*/
const approvedRateByRole = (rates) => {
const map = new Map();
for (const rate of rates) {
if (!rate.is_approved_rate || !rate.role_name) continue;
const current = map.get(rate.role_name);
const value = num(rate.client_rate);
if (!value) continue;
if (current == null || value < current) map.set(rate.role_name, value);
}
return map;
};
/** The cheapest bill rate any vendor has actually charged for a role. */
const bestObservedRateByRole = (assignments) => {
const map = new Map();
for (const a of assignments) {
const rate = num(a.bill_rate);
if (!a.role || !rate) continue;
const current = map.get(a.role);
if (current == null || rate < current) map.set(a.role, rate);
}
return map;
};
/* ---------------------------------------------------------- opportunities */
const IMPACT = (share) => (share >= 25 ? "high" : share >= 10 ? "medium" : "low");
/**
* The savings the workspace's own records support.
*
* Each entry carries the records it was read from, so an answer can state its
* basis rather than asserting a figure. An opportunity with no basis in the
* data is not produced at all — which is why this returns a list whose length
* changes with the workspace instead of a fixed set of five.
*/
function deriveOpportunities({ assignments, vendors, rates, metrics }) {
const found = [];
/* 1 — Spend running outside a contracted rate. */
if (metrics.nonContractedSpend > 0) {
const monthly = num(metrics.potentialSavings);
found.push({
id: "opp-contract-conversion",
title: "Contract conversion",
category: "Vendor",
monthly,
annual: monthly * 12,
effort: "medium",
status: metrics.contractedRatio >= 70 ? "Review" : "Ready",
confidence: Math.min(95, round(metrics.contractedRatio) + 20),
owner: "Procurement",
weeksToValue: 6,
route: "/VendorManagement",
detail: `${round(metrics.nonContractedSpend).toLocaleString()} dollars of ${round(metrics.totalSpend).toLocaleString()} in billed spend runs outside a contracted rate. Moving it from the spot average of $${metrics.avgNonContractedRate.toFixed(2)}/hr to the contracted $${metrics.avgContractedRate.toFixed(2)}/hr closes a ${metrics.potentialSavingsPercent.toFixed(1)}% spread.`,
basis: `${round(metrics.contractedRatio)}% of spend is under contract today.`,
});
}
/* 2 — Assignments billed above the approved rate book. */
const approved = approvedRateByRole(rates);
if (approved.size) {
let overage = 0;
const roles = new Map();
for (const a of assignments) {
const book = approved.get(a.role);
const billed = num(a.bill_rate);
if (!book || !billed || billed <= book) continue;
const share = (billed - book) / billed;
const amount = num(a.total_bill) * share;
overage += amount;
roles.set(a.role, num(roles.get(a.role)) + amount);
}
if (overage > 0) {
const worst = [...roles.entries()].sort((a, b) => b[1] - a[1])[0];
found.push({
id: "opp-rate-drift",
title: "Rate card drift",
category: "Rates",
monthly: overage,
annual: overage * 12,
effort: "low",
status: "Ready",
confidence: 90,
owner: "Procurement",
weeksToValue: 2,
route: "/VendorRates",
detail: `${roles.size} role${roles.size === 1 ? " is" : "s are"} being billed above the approved rate book${worst ? `, led by ${worst[0]} at $${round(worst[1]).toLocaleString()} of the overage` : ""}. Nothing needs renegotiating — the contracted rate already exists.`,
basis: `Checked against ${approved.size} approved role rate${approved.size === 1 ? "" : "s"}.`,
});
}
}
/* 3 — The same role filled at different rates by different vendors. */
const best = bestObservedRateByRole(assignments);
const vendorsByRole = new Map();
for (const a of assignments) {
if (!a.role || !a.vendor_id) continue;
if (!vendorsByRole.has(a.role)) vendorsByRole.set(a.role, new Set());
vendorsByRole.get(a.role).add(a.vendor_id);
}
const splitRoles = [...vendorsByRole.entries()].filter(([, set]) => set.size > 1);
if (splitRoles.length) {
let spread = 0;
for (const a of assignments) {
const floor = best.get(a.role);
const billed = num(a.bill_rate);
if (!floor || !billed || billed <= floor) continue;
if (!vendorsByRole.get(a.role) || vendorsByRole.get(a.role).size < 2) continue;
spread += num(a.total_bill) * ((billed - floor) / billed);
}
if (spread > 0) {
const supplying = new Set(assignments.filter((a) => a.vendor_id).map((a) => a.vendor_id)).size;
found.push({
id: "opp-vendor-consolidation",
title: "Vendor consolidation",
category: "Vendor",
monthly: spread,
annual: spread * 12,
effort: "medium",
status: "Active",
confidence: 82,
owner: "Procurement",
weeksToValue: 8,
route: "/VendorManagement",
detail: `${splitRoles.length} role${splitRoles.length === 1 ? " is" : "s are"} supplied by more than one vendor at different bill rates across ${supplying} supplying vendor${supplying === 1 ? "" : "s"}. Consolidating each role onto its lowest observed rate closes the spread.`,
basis: splitRoles.map(([role]) => role).slice(0, 4).join(", "),
});
}
}
/* 4 — Cost carried by shifts that were not worked. */
if (metrics.noShowRate > 0 && metrics.totalSpend > 0) {
const monthly = num(metrics.totalSpend) * (num(metrics.noShowRate) / 100);
found.push({
id: "opp-no-show",
title: "No-show leakage",
category: "Labor",
monthly,
annual: monthly * 12,
effort: "medium",
status: "Active",
confidence: 71,
owner: "Operations",
weeksToValue: 8,
route: "/StaffDirectory",
detail: `A ${metrics.noShowRate.toFixed(1)}% no-show rate across ${metrics.totalWorkforce} workers carries replacement cover at premium rates. Reliability currently averages ${metrics.avgReliability.toFixed(0)}%.`,
basis: `${metrics.totalWorkforce} workforce records with attendance history.`,
});
}
/* 5 — Vendors carrying no work at all. */
const supplying = new Set(assignments.map((a) => a.vendor_id).filter(Boolean));
const idle = vendors.filter((v) => v.is_active && !supplying.has(v.id));
if (idle.length > 1 && metrics.totalSpend > 0) {
/* No spend flows through these, so the saving is the administrative and
compliance carry rather than a rate — modelled from the fee they would
take on an average order's worth of work. */
const monthly = safeDiv(metrics.totalSpend, Math.max(supplying.size, 1)) * 0.05 * idle.length;
found.push({
id: "opp-vendor-tail",
title: "Inactive vendor tail",
category: "Vendor",
monthly,
annual: monthly * 12,
effort: "low",
status: "Review",
confidence: 64,
owner: "Procurement",
weeksToValue: 4,
route: "/VendorManagement",
detail: `${idle.length} active vendor${idle.length === 1 ? " carries" : "s carry"} no assignments in this window while ${supplying.size} supply all billed work. Each one still costs compliance review and rate-book upkeep.`,
basis: idle.map((v) => v.doing_business_as || v.legal_name).filter(Boolean).slice(0, 4).join(", "),
});
}
const total = found.reduce((n, o) => n + o.annual, 0);
return found
.map((o) => ({
...o,
annual: round(o.annual),
monthly: round(o.monthly),
share: round(safeDiv(o.annual, total) * 100),
}))
.map((o) => ({ ...o, impact: IMPACT(o.share) }))
.sort((a, b) => b.annual - a.annual);
}
/* ------------------------------------------------------------------ risks */
/**
* Savings already in hand that the workspace is on course to give back.
*
* All three read a real expiry or a real score. A workspace with none of those
* conditions returns an empty list, and the risk answer says so.
*/
function deriveRisks({ assignments, vendors, workforce, rates, metrics }) {
const risks = [];
const spendByVendor = new Map();
for (const a of assignments) {
if (!a.vendor_id) continue;
spendByVendor.set(a.vendor_id, num(spendByVendor.get(a.vendor_id)) + num(a.total_bill));
}
/* Insurance lapsing on a vendor that is currently carrying work. */
for (const vendor of vendors) {
const due = daysUntil(vendor.insurance_expiry);
const spend = num(spendByVendor.get(vendor.id));
if (due == null || due > 90 || !spend) continue;
risks.push({
id: `risk-insurance-${vendor.id}`,
title: `${vendor.doing_business_as || vendor.legal_name} insurance expiry`,
exposure: round(spend),
severity: due <= 30 ? "high" : "medium",
dueInDays: Math.max(due, 0),
detail: `Coverage lapses in ${Math.max(due, 0)} days on a vendor carrying $${round(spend).toLocaleString()} of billed work. Assignments cannot be placed against a lapsed certificate.`,
action: "Request renewal",
route: "/VendorCompliance",
});
}
/* A contracted rate about to expire is the discount about to expire. */
const bySpendRole = new Map();
for (const a of assignments) {
if (!a.role) continue;
bySpendRole.set(a.role, num(bySpendRole.get(a.role)) + num(a.total_bill));
}
const expiring = rates
.filter((r) => r.is_approved_rate)
.map((r) => ({ rate: r, due: daysUntil(r.valid_until) }))
.filter((r) => r.due != null && r.due <= 60 && num(bySpendRole.get(r.rate.role_name)) > 0);
if (expiring.length) {
const soonest = expiring.sort((a, b) => a.due - b.due)[0];
const exposure = expiring.reduce(
(n, r) => n + num(bySpendRole.get(r.rate.role_name)) * (num(metrics.potentialSavingsPercent) / 100), 0
);
risks.push({
id: "risk-rate-expiry",
title: `${expiring.length} contracted rate${expiring.length === 1 ? "" : "s"} expiring`,
exposure: round(exposure),
severity: soonest.due <= 21 ? "high" : "medium",
dueInDays: Math.max(soonest.due, 0),
detail: `${soonest.rate.role_name} expires first, in ${Math.max(soonest.due, 0)} days. Work billed against an expired rate reverts to spot pricing.`,
action: "Renew rate book",
route: "/VendorRates",
});
}
/* Workers whose reliability is dragging the fill rate the savings rest on. */
const weak = workforce.filter((w) => num(w.total_assignments) > 0 && num(w.reliability_index) < 70);
if (weak.length) {
const exposure = num(metrics.totalSpend) * (num(metrics.noShowRate) / 100);
risks.push({
id: "risk-reliability",
title: `${weak.length} worker${weak.length === 1 ? "" : "s"} below reliability threshold`,
exposure: round(exposure),
severity: weak.length > 5 ? "high" : "medium",
dueInDays: 0,
detail: `${weak.length} of ${workforce.length} workers score under 70 on reliability while holding live assignments. Premium cover for their gaps is what the no-show leakage above is made of.`,
action: "Review coverage",
route: "/StaffDirectory",
});
}
return risks.sort((a, b) => b.exposure - a.exposure);
}
/* --------------------------------------------------------- series & feeds */
/** Monthly savings run-rate, from the dates the assignments actually carry. */
function deriveTrend(assignments, metrics) {
const buckets = new Map();
for (const a of assignments) {
const d = asDate(a.scheduled_start);
if (!d) continue;
const key = monthKey(d);
const entry = buckets.get(key) || { key, label: monthLabel(d), spend: 0, contracted: 0 };
entry.spend += num(a.total_bill);
if (a.vendor_id) entry.contracted += num(a.total_bill);
buckets.set(key, entry);
}
const pctRate = num(metrics.potentialSavingsPercent) / 100;
return [...buckets.values()]
.sort((a, b) => a.key.localeCompare(b.key))
.slice(-6)
.map((b) => ({
label: b.label,
value: round((b.spend - b.contracted) * pctRate),
spend: round(b.spend),
contracted: round(b.contracted),
}));
}
/** What the workspace has done lately, read from the records themselves. */
function deriveActivity(assignments, orders) {
const events = [];
for (const a of assignments) {
const when = a.updated_date || a.created_date || a.scheduled_start;
const time = relativeTime(when);
if (!time) continue;
events.push({
at: asDate(when).getTime(),
time,
label: `${a.role || "Assignment"} ${String(a.assignment_status || "updated").toLowerCase()}`,
caption: [a.vendor_name, num(a.total_bill) ? `$${round(a.total_bill).toLocaleString()}` : null]
.filter(Boolean).join(" · "),
tone: a.assignment_status === "Completed" ? "positive"
: a.assignment_status === "Canceled" ? "risk" : "neutral",
});
}
for (const o of orders) {
const when = o.updated_date || o.created_date || o.date;
const time = relativeTime(when);
if (!time) continue;
events.push({
at: asDate(when).getTime(),
time,
label: `Order ${String(o.order_status || o.status || "updated").toLowerCase()}`,
caption: o.event_name || o.order_number || o.id,
tone: o.order_status === "Completed" ? "positive" : "neutral",
});
}
/* `at` is the sort key only — it never reaches a block. */
return events
.sort((a, b) => b.at - a.at)
.slice(0, 6)
.map((e) => ({ time: e.time, label: e.label, caption: e.caption, tone: e.tone }));
}
/* --------------------------------------------------------------- pipeline */
/**
* How the engine reaches a number, with this workspace's own figures on each
* step. The stages are the engine's; the counts are the page's.
*/
function derivePipeline({ metrics, opportunities, risks, collections }) {
const money = (v) => `$${round(v).toLocaleString()}`;
const compact = (v) => `$${(round(v) / 1000).toFixed(1)}K`;
const sources = Object.entries(collections).filter(([, list]) => list.length);
const automatable = opportunities.filter((o) => o.effort === "low");
const approvals = opportunities.filter((o) => o.annual >= 10000);
const avgConfidence = opportunities.length
? round(opportunities.reduce((n, o) => n + o.confidence, 0) / opportunities.length) : 0;
const avgWeeks = opportunities.length
? (opportunities.reduce((n, o) => n + o.weeksToValue, 0) / opportunities.length).toFixed(1) : "0";
return [
{
id: "sources", label: "Data sources", caption: sources.map(([name]) => name).join(", ") || "none connected",
tone: sources.length ? "neutral" : "warning",
detail: {
title: "Data sources",
lines: [`Owliver reads ${sources.length} connected collection${sources.length === 1 ? "" : "s"} on this page.`],
metrics: sources.map(([name, list]) => ({ label: name, value: String(list.length) })),
},
},
{
id: "spend", label: "Identify spend", caption: `${money(metrics.totalSpend)} billed`, tone: "neutral",
detail: {
title: "Spend identification",
lines: [`Every assignment line is matched to a vendor and a role before it can be scored.`],
metrics: [
{ label: "Billed", value: money(metrics.totalSpend) },
{ label: "Under contract", value: `${round(metrics.contractedRatio)}%` },
{ label: "Assignments", value: String(collections.assignments.length) },
],
},
},
{
id: "detect", label: "Detect opportunity", caption: `${opportunities.length} found`,
tone: opportunities.length ? "info" : "neutral",
detail: {
title: "Opportunity detection",
lines: opportunities.length
? [`${opportunities.length} opportunities found across the records this page carries.`]
: ["Nothing in this workspace currently clears the detection threshold."],
metrics: ["high", "medium", "low"].map((impact) => ({
label: impact === "high" ? "High impact" : impact === "medium" ? "Medium" : "Low",
value: String(opportunities.filter((o) => o.impact === impact).length),
})),
route: "/SavingsEngine", actionLabel: "View opportunities",
},
},
{
id: "calculate", label: "Calculate savings", caption: `${compact(metrics.potentialSavings * 12)} annualised`, tone: "info",
detail: {
title: "Savings calculation",
lines: ["Each opportunity is modelled over twelve months of run rate and discounted by a confidence score."],
metrics: [
{ label: "Modelled", value: compact(metrics.potentialSavings * 12) },
{ label: "Avg confidence", value: `${avgConfidence}%` },
],
},
},
{
id: "recommend", label: "Create recommendation", caption: `avg ${avgWeeks} weeks to value`, tone: "info",
detail: {
title: "Recommendation",
lines: ["Each opportunity is routed to the team that owns the change, with an effort estimate."],
metrics: [...new Set(opportunities.map((o) => o.owner))].map((owner) => ({
label: owner, value: String(opportunities.filter((o) => o.owner === owner).length),
})),
},
},
{
id: "approval", label: "Approval", caption: `${approvals.length} above $10K`,
tone: approvals.length ? "warning" : "neutral",
detail: {
title: "Approval",
lines: ["Anything above $10K annualised needs finance sign-off before it can run unattended."],
metrics: [
{ label: "Awaiting", value: String(approvals.length) },
{ label: "Auto-eligible", value: String(opportunities.length - approvals.length) },
],
},
},
{
id: "automation", label: "Automation", caption: `${automatable.length} can run unattended`,
tone: automatable.length ? "positive" : "neutral",
detail: {
title: "Automation",
lines: ["A low-effort opportunity needs no renegotiation, so it becomes a standing rule that re-checks every cycle."],
metrics: [
{ label: "Low effort", value: String(automatable.length) },
{ label: "Needs negotiation", value: String(opportunities.length - automatable.length) },
],
},
},
{
id: "captured", label: "Savings captured", caption: `${money(metrics.contractedSpend)} under contract`, tone: "positive",
detail: {
title: "Savings captured",
lines: [`${money(metrics.contractedSpend)} of billed spend already runs at contracted rates — that is the saving the workspace is holding.`],
metrics: [
{ label: "Under contract", value: money(metrics.contractedSpend) },
{ label: "At risk", value: money(risks.reduce((n, r) => n + r.exposure, 0)) },
],
},
},
];
}
/* =========================================================== the workspace */
/**
* The Saving Engine page, as the object Owliver answers from.
*
* `metrics` and `projections` are taken from the page when it publishes them —
* the page computed them to draw its own cards, and reusing them is what makes
* the panel and the cards one reading. Everything else is derived here from the
* records the page already loaded.
*/
export function buildSavingsWorkspace(page = {}) {
const {
assignments = [], vendors = [], workforce = [], orders = [], rates = [],
activeTab = "overview", timeRange = "30days", userRole = "admin",
} = page || {};
const collections = { assignments, vendors, workforce, orders, rates };
const metrics = page.metrics || deriveMetrics(collections);
const projections = page.projections || deriveProjections(metrics);
const opportunities = deriveOpportunities({ assignments, vendors, rates, metrics });
const risks = deriveRisks({ assignments, vendors, workforce, rates, metrics });
const trend = deriveTrend(assignments, metrics);
const recentActivity = deriveActivity(assignments, orders);
const byCategory = [...opportunities.reduce((acc, o) => {
acc.set(o.category, num(acc.get(o.category)) + o.annual);
return acc;
}, new Map())].map(([label, value]) => ({ label, value })).sort((a, b) => b.value - a.value);
const atRisk = risks.reduce((n, r) => n + r.exposure, 0);
const projected = projectedFor(projections, timeRange);
/* The previous window, only where the dated records actually cover one. A
trend claim with nothing behind it is worse than no trend claim. */
const previousPeriodSavings = trend.length >= 2 ? trend[trend.length - 2].value : null;
return {
view: {
activeTab, timeRange, userRole,
timeLabel: TIME_LABEL[timeRange] || "30-day",
},
totals: {
/* The headline the page's own cards show for the selected range. */
totalSavings: round(projected),
previousPeriodSavings,
capturedThisMonth: trend.length ? trend[trend.length - 1].value : 0,
monthlyPotential: round(projections.thirtyDays),
annualisedPotential: round(projections.year),
opportunities: opportunities.length,
/* A rule is a detection this workspace's records support; an automation is
one that needs no renegotiation to run. Both are counted, never named. */
activeRules: opportunities.length,
automations: opportunities.filter((o) => o.effort === "low").length,
atRisk,
recentActivity: recentActivity.length,
totalSpend: round(metrics.totalSpend),
contractedSpend: round(metrics.contractedSpend),
nonContractedSpend: round(metrics.nonContractedSpend),
contractedRatio: metrics.contractedRatio,
potentialSavingsPercent: metrics.potentialSavingsPercent,
avgContractedRate: metrics.avgContractedRate,
avgNonContractedRate: metrics.avgNonContractedRate,
rateDelta: metrics.avgNonContractedRate - metrics.avgContractedRate,
fillRate: metrics.fillRate,
avgReliability: metrics.avgReliability,
noShowRate: metrics.noShowRate,
activeVendors: metrics.activeVendors,
totalWorkforce: metrics.totalWorkforce,
completedOrders: metrics.completedOrders,
assignments: assignments.length,
},
opportunities,
risks,
trend,
byCategory,
recentActivity,
pipeline: derivePipeline({ metrics, opportunities, risks, collections }),
};
}
export default buildSavingsWorkspace;

View File

@@ -1,164 +0,0 @@
/**
* Order Management — local demo fixture
* -------------------------------------
* Nine orders in the real `Event` shape (src/entities/Event.json), used to
* demonstrate the compact Owliver panel on /events without touching the page's
* own data pipeline.
*
* Nothing here is a count. Every number Owliver quotes is derived from these
* objects by PAGE_CONTEXTS.orders.summarise(), so the suggestions and answers
* stay honest about the data in front of them — and swapping to live orders is
* the one-line change at USE_DEMO_ORDERS below.
*
* Dates are anchored to DEMO_TODAY rather than `new Date()`: a fixture that
* drifts with the wall clock silently changes its own upcoming/past split and
* stops matching what the page shows.
*/
/** The "today" this fixture is written around. */
export const DEMO_TODAY = "2026-08-17";
/**
* Flip to false to point Owliver at the page's live `eventsWithConflicts`
* instead (see the registration call in src/pages/Events.jsx).
*/
export const USE_DEMO_ORDERS = true;
/** One shift with a single role — enough for the seat maths in summarise(). */
const shift = (start, end, role, count) => ({
location: "Main Floor",
roles: [{ role, department: "Operations", count, start_time: start, end_time: end }],
});
/** `count` placeholder assignments, so assigned_staff.length is the fill. */
const filled = (count) =>
Array.from({ length: count }, (_, i) => ({
staff_id: `demo-staff-${i + 1}`,
staff_name: `Assigned Worker ${i + 1}`,
role: "Crew",
}));
/**
* Derived tallies, for reference — all computed by summarise(), none stored:
* total 9 · rapid 1 · requested (Pending) 1 · partially staffed 3
* fully staffed 1 · active 1 · past (Completed) 2 · draft 1
* conflicts 0 · upcoming (date > DEMO_TODAY) 5
*
* Staffing status is assigned_staff.length vs the sum of shifts[].roles[].count,
* exactly as Events.jsx computes it. Only the orders being worked right now
* carry a roster here: draft, pending, active and closed-out orders are left
* with an empty assigned_staff, which is what keeps "fully staffed" at 1 rather
* than sweeping in the two completed orders.
*/
export const DEMO_ORDERS = [
{
id: "demo-order-1",
event_name: "Vendor Fair — Facilities Support",
business_name: "Northbay Tech Campus",
hub: "Northbay HQ",
date: "2026-08-31",
status: "Draft",
is_rapid: false,
shifts: [shift("07:00", "15:00", "Facilities Attendant", 8)],
assigned_staff: [],
detected_conflicts: [],
},
{
id: "demo-order-2",
event_name: "Freshman Move-In Dining Surge",
business_name: "Peninsula University Dining",
hub: "Peninsula Commons",
date: "2026-08-26",
status: "Pending",
is_rapid: false,
shifts: [shift("06:30", "14:30", "Dining Attendant", 14)],
assigned_staff: [],
detected_conflicts: [],
},
{
id: "demo-order-3",
event_name: "Quarterly Partner Summit",
business_name: "Harbor Point Conference Center",
hub: "Harbor Point",
date: "2026-08-24",
status: "Partial Staffed",
is_rapid: false,
shifts: [shift("08:00", "17:00", "Event Server", 12)],
assigned_staff: filled(5),
detected_conflicts: [],
},
{
id: "demo-order-4",
event_name: "Stadium Concessions — Season Opener",
business_name: "Riverside Sports Group",
hub: "Riverside Arena",
date: "2026-08-21",
status: "Partial Staffed",
is_rapid: true,
shifts: [shift("14:00", "23:00", "Concessions Crew", 20)],
assigned_staff: filled(9),
detected_conflicts: [],
},
{
id: "demo-order-5",
event_name: "Warehouse Inventory Count",
business_name: "Cedar Grove Logistics",
hub: "Cedar Grove DC",
date: "2026-08-19",
status: "Fully Staffed",
is_rapid: false,
shifts: [shift("05:00", "13:00", "Warehouse Associate", 6)],
assigned_staff: filled(6),
detected_conflicts: [],
},
{
id: "demo-order-6",
event_name: "Executive Briefing Catering",
business_name: "Lakeshore Financial",
hub: "Lakeshore Tower",
date: "2026-08-17",
status: "Active",
is_rapid: false,
shifts: [shift("09:00", "16:00", "Catering Lead", 4)],
assigned_staff: [],
detected_conflicts: [],
},
{
id: "demo-order-7",
event_name: "Retail Floor Reset",
business_name: "Fairview Retail Group",
hub: "Fairview Center",
date: "2026-08-17",
status: "Partial Staffed",
is_rapid: false,
shifts: [shift("18:00", "23:30", "Merchandiser", 10)],
assigned_staff: filled(3),
detected_conflicts: [],
},
{
id: "demo-order-8",
event_name: "Alumni Weekend Banquet",
business_name: "Peninsula University Dining",
hub: "Peninsula Commons",
date: "2026-08-08",
status: "Completed",
is_rapid: false,
shifts: [shift("15:00", "22:00", "Banquet Server", 18)],
assigned_staff: [],
detected_conflicts: [],
},
{
id: "demo-order-9",
event_name: "Data Center Maintenance Window",
business_name: "Northbay Tech Campus",
hub: "Northbay HQ",
date: "2026-08-02",
status: "Completed",
is_rapid: false,
shifts: [shift("22:00", "06:00", "Facilities Technician", 5)],
assigned_staff: [],
detected_conflicts: [],
},
];
export default DEMO_ORDERS;

View File

@@ -539,8 +539,10 @@ const generic = (ctx) => {
* @param {Array} args.conversation the thread *before* this prompt
* @returns {Promise<string>} markdown answer — never throws, never empty
*/
export async function getOwliverResponse({ prompt, pageContext, conversation } = {}) {
await wait(LATENCY_MIN_MS + Math.random() * (LATENCY_MAX_MS - LATENCY_MIN_MS));
export async function getOwliverResponse({ prompt, pageContext, conversation, skipLatency } = {}) {
// The panel now stages its own thinking line, so a caller that is already
// pacing the turn asks for the answer without the deliberation beat.
if (!skipLatency) await wait(LATENCY_MIN_MS + Math.random() * (LATENCY_MAX_MS - LATENCY_MIN_MS));
const ctx = {
domain: pageContext?.domain || "command",

View File

@@ -15,6 +15,8 @@
*/
import { krow } from "@/api/client";
import { buildSavingsWorkspace } from "./knowledge/savingsWorkspace";
import { buildAnalyticsWorkspace } from "./knowledge/analyticsWorkspace";
/* ------------------------------------------------------------------ utils */
@@ -399,38 +401,79 @@ export const DOMAINS = {
},
},
/**
* Saving Engine.
*
* The route-level fallback for when the panel is opened before the page has
* mounted and registered itself. It reads the same five collections the page
* reads and runs the same derivation, so the answer is identical either way —
* this is a second entry point to one reading, not a second reading.
*/
savingEngine: {
key: "savingEngine",
working: "Reading Saving Engine data…",
placeholder: "Ask Owliver about Saving Engine...",
title: "Saving Engine",
entity: "Assignment",
async loadSignals() {
const [assignments, vendors, workforce, orders, rates] = await Promise.all([
listSafe("Assignment"), listSafe("Vendor"), listSafe("Workforce"),
listSafe("Order"), listSafe("VendorRate"),
]);
return buildSavingsWorkspace({ assignments, vendors, workforce, orders, rates });
},
buildInsight(d) {
const t = d?.totals || {};
return {
headline: `${plural(t.opportunities || 0, "savings opportunity", "savings opportunities")} detected on this workspace.`,
supporting: `Reading ${plural(t.assignments || 0, "assignment", "assignments")} — $${Math.round((t.monthlyPotential || 0) / 1000)}K of monthly potential against $${Math.round((t.totalSpend || 0) / 1000)}K of billed spend.`,
};
},
buildSuggestions(d) {
const t = d?.totals || {};
return [
s("Give me a summary", "Give me a summary", 0),
t.opportunities > 0 && s("Show my biggest opportunities", "Show my biggest savings opportunities", 0),
t.atRisk > 0 && s("What savings are at risk?", "What savings are at risk?", 1),
s("Where are we saving the most?", "Where are we saving the most?", 1),
s("Explain the savings trend", "Explain the savings trend", 2),
s("How does Saving Engine work?", "How does the Saving Engine work?", 3),
].filter(Boolean);
},
},
analytics: {
key: "analytics",
working: "Reading the numbers…",
placeholder: "Ask Owliver about performance trends...",
title: "Operational Intelligence",
entity: "Event",
/* The route-level fallback: the same two collections the Reports page
reads, through the same derivation, so a question answered before the
page mounts and one answered after it cannot disagree. */
async loadSignals() {
const [events, invoices] = await Promise.all([
listSafe("Event", "-date", 250),
listSafe("Invoice", "-issue_date", 250),
]);
const completed = events.filter((e) => e.status === "Completed");
return {
events: events.length,
completed: completed.length,
revenue: invoices.reduce((n, i) => n + (i.amount || 0), 0),
invoices: invoices.length,
};
return buildAnalyticsWorkspace({ events, invoices });
},
buildInsight(d) {
const t = d?.totals || {};
return {
headline: `${plural(d.completed, "order", "orders")} completed out of ${d.events} tracked.`,
supporting: `Reading ${plural(d.invoices, "invoice", "invoices")} and order history — trends, anomalies and what changed.`,
headline: `${plural(t.completed || 0, "order", "orders")} completed out of ${t.events || 0} tracked.`,
supporting: `Reading ${plural(t.invoices || 0, "invoice", "invoices")} and order history — trends, anomalies and what changed.`,
};
},
buildSuggestions() {
buildSuggestions(d) {
const t = d?.totals || {};
return [
s("What changed this week?", "What changed this week compared to last week? Focus on anything unusual.", 0),
s("What needs attention?", "What in the numbers needs my attention?", 1),
s("Identify operational risks", "Identify the operational risks hiding in this data.", 2),
s("Where is margin leaking?", "Where am I leaking margin, and what would fix it?", 3),
];
s("Give me a summary", "Give me a summary", 0),
(d?.timeline?.length || 0) > 1 && s("What changed over the period?", "What changed over the period?", 1),
(d?.byDept?.length || 0) > 0 && s("Which department costs most?", "Which department costs the most?", 1),
(t.openShifts > 0 || t.fillRatePct < 95) && s("What needs attention?", "What in the numbers needs my attention?", 2),
s("Where is margin leaking?", "Where is margin leaking?", 3),
].filter(Boolean);
},
},
@@ -576,7 +619,8 @@ const PAGE_DOMAIN = {
Business: "network", AddBusiness: "network", EditBusiness: "network",
// Analytics
Reports: "analytics", SavingsEngine: "analytics",
Reports: "analytics",
SavingsEngine: "savingEngine",
PredictiveIntelligence: "analytics", ActivityLog: "analytics",
KrowEdge: "analytics", DemandSupply: "analytics",
ClientLaborReport: "analytics", FeatureReport: "analytics",
@@ -1266,3 +1310,96 @@ PAGE_CONTEXTS.orders = {
];
},
};
/* ------------------------------------------------------------ Saving Engine */
/**
* The Saving Engine page's own records, published straight from the page.
*
* `summarise` does nothing but hand the page's data to the shared derivation —
* the same one `DOMAINS.savingEngine.loadSignals()` runs — so the panel and the
* cards on the page are two readings of one computation. `metrics` and
* `projections` come across because the page already computed them to draw its
* own cards; reusing them is what stops the two from drifting apart.
*/
PAGE_CONTEXTS.savingEngine = {
key: "savingEngine",
title: "Saving Engine",
entity: "Assignment",
placeholder: "Ask Owliver about Saving Engine...",
working: "Reading Saving Engine data",
summarise: (data = {}) => buildSavingsWorkspace(data || {}),
buildInsight(d) {
const t = d?.totals || {};
const lead = d?.opportunities?.[0];
return {
headline: lead
? `${lead.title} is the largest savings opportunity on this page.`
: `${plural(Math.round(t.contractedRatio || 0), "percent", "percent")} of billed spend is already under contract.`,
supporting: `Reading ${plural(t.assignments || 0, "assignment", "assignments")} across ${plural(t.activeVendors || 0, "active vendor", "active vendors")} — $${Math.round((t.monthlyPotential || 0) / 1000)}K of monthly potential${t.atRisk ? `, $${Math.round(t.atRisk / 1000)}K at risk` : ""}.`,
};
},
buildSuggestions(d) {
const t = d?.totals || {};
const lead = d?.opportunities?.[0];
return [
s("Give me a summary", "Give me a summary", 0),
t.opportunities > 0 && s("Show my biggest opportunities", "Show my biggest savings opportunities", 0),
t.atRisk > 0 && s("What savings are at risk?", "What savings are at risk?", 1),
(d?.byCategory?.length || 0) > 1 && s("Where are we saving the most?", "Where are we saving the most?", 1),
lead && s(`Why is ${lead.title.toLowerCase()} #1?`, `Why is ${lead.title} the number one saving?`, 2),
(d?.trend?.length || 0) > 1 && s("Explain the savings trend", "Explain the savings trend", 2),
s("How does Saving Engine work?", "How does the Saving Engine work?", 3),
s("Show the important numbers", "Show the important numbers", 4),
].filter(Boolean);
},
};
/* ---------------------------------------------------------------- Analytics */
/**
* The Reports page's own figures, published from the report itself rather than
* from the route.
*
* Registering inside `ClientTrendsReport` rather than in `Reports.jsx` is what
* gives Owliver the period the reader has selected: the same records under a
* 7-day and an all-time filter are two different pages, and answering about the
* wrong one is the mistake this channel exists to prevent.
*/
PAGE_CONTEXTS.analytics = {
key: "analytics",
title: "Operational Intelligence",
entity: "Event",
placeholder: "Ask Owliver about trends, metrics, or performance...",
working: "Reading the numbers",
summarise: (data = {}) => buildAnalyticsWorkspace(data || {}),
buildInsight(d) {
const t = d?.totals || {};
const lead = d?.byDept?.[0] || d?.byHub?.[0];
return {
headline: lead
? `${lead.name} is the largest cost centre on this report.`
: `${plural(t.completed || 0, "order", "orders")} completed out of ${t.events || 0} tracked.`,
supporting: `Reading ${plural(t.events || 0, "order", "orders")} and ${plural(t.invoices || 0, "invoice", "invoices")} over the ${d?.view?.periodLabel || "tracked window"} — $${Math.round((t.spend || 0) / 1000)}K of labor spend at a ${Math.round(t.fillRatePct || 0)}% fill rate.`,
};
},
buildSuggestions(d) {
const t = d?.totals || {};
const dept = d?.byDept?.[0];
return [
s("Give me a summary", "Give me a summary", 0),
(d?.timeline?.length || 0) > 1 && s("What changed over the period?", "What changed over the period?", 1),
dept && s(`Why is ${dept.name} the biggest cost?`, "Which department costs the most?", 1),
(d?.byPosition?.length || 0) > 0 && s("Which position costs most?", "Which position costs the most?", 2),
(t.openShifts > 0 || t.fillRatePct < 95) && s("What needs attention?", "What in the numbers needs my attention?", 2),
s("Where is margin leaking?", "Where is margin leaking?", 3),
t.scheduledHours > 0 && s("Scheduled vs worked hours", "Show scheduled against worked hours", 4),
].filter(Boolean);
},
};

View File

@@ -0,0 +1,75 @@
/**
* Owliver response engine
* -----------------------
* One entry point for one turn. It resolves the knowledge pack for the page the
* user is actually on, hands it the question plus the thread's state, and
* returns a staged answer:
*
* { topic, stages: string[], blocks: OwliverBlock[], followUps: [] }
*
* `stages` are the lines shown while the answer is being composed ("Checking 27
* opportunities…"), `blocks` are the answer itself and `followUps` are the
* questions this particular answer makes worth asking next. The provider owns
* the pacing; this module owns the content.
*
* Nothing about the page's data is fetched here — `signals` is whatever the page
* already published. When a real Saving Engine API or model response arrives, it
* enters at exactly this seam: a pack whose `answer()` awaits a network call and
* returns the same block array.
*/
import getKnowledge from "./knowledge";
import { blocks, text, actions } from "./blocks";
/** The last thing Owliver answered, so a pronoun has something to bind to. */
export const lastTopicOf = (conversation = []) => {
for (let i = conversation.length - 1; i >= 0; i -= 1) {
const m = conversation[i];
if (m.role === "assistant" && m.topic) return m.topic;
}
return null;
};
const errorAnswer = () => ({
topic: null,
stages: [],
error: true,
blocks: blocks(
text("Something went wrong while analyzing this workspace."),
actions([{ label: "Try again", retry: true }])
),
followUps: [],
});
/**
* @returns {Promise<{topic, stages, blocks, followUps, error?}>}
*/
export async function getOwliverAnswer({
prompt, domainKey, domain, signals, pageContext, conversation = [],
} = {}) {
const pack = getKnowledge(domainKey, domain);
try {
const answer = await pack.answer({
prompt,
signals,
pageContext,
conversation,
lastTopic: lastTopicOf(conversation),
});
if (!answer || !answer.blocks?.length) return errorAnswer();
return {
topic: answer.topic ?? null,
stages: answer.stages || [],
blocks: answer.blocks,
followUps: answer.followUps || [],
};
} catch (err) {
console.error("Owliver: answer failed", err);
return errorAnswer();
}
}
export default getOwliverAnswer;

View File

@@ -2,11 +2,6 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from '@/App.jsx'
import '@/index.css'
// After index.css on purpose. Astryx's atomic classes and Tailwind's preflight
// have the same specificity, so whichever loads last wins; with Astryx first,
// preflight's `button { padding: 0; background: transparent }` flattened every
// Astryx control. Import order here is the load order.
import '@astryxdesign/core/astryx.css'
ReactDOM.createRoot(document.getElementById('root')).render(
// <React.StrictMode>

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import {
Building2, Briefcase, Package, RefreshCw, Download, Filter, Wallet, Brain
} from "lucide-react";
import PageHeader from "../components/common/PageHeader";
import useOwliverPageContext from "@/components/owliver/useOwliverPageContext";
import SavingsOverviewCards from "../components/savings/SavingsOverviewCards";
import ContractConversionMap from "../components/savings/ContractConversionMap";
import PredictiveSavingsModel from "../components/savings/PredictiveSavingsModel";
@@ -193,6 +194,18 @@ export default function SavingsEngine() {
const roleTabs = getRoleTabs();
// Owliver answers this page from the records this page has already loaded.
// `metrics` and `projections` are the same objects the cards below render
// from, so the panel and the page cannot quote different figures.
useOwliverPageContext("savingEngine", {
assignments, vendors, workforce, orders, rates,
metrics, projections, activeTab, timeRange, userRole,
// A primitive the registration signature can see: `metrics` is an object,
// and re-registering only when a collection's length changes would leave
// Owliver holding a stale reading after an in-place record update.
spend: Math.round(metrics.totalSpend),
});
return (
<div className="p-4 md:p-8 bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20 min-h-screen">
<div className="max-w-[1800px] mx-auto">