import { BENEFITS, CLIENTS, COMPLIANCE_RISKS, CONVERSATIONS, DEPARTMENT_METRICS, INVOICES, ORDERS, ORDER_SUMMARY, PAYROLL_LINES, PAYROLL_SUMMARY, REPORTS, REPORTS_SUMMARY, SCHEDULE, SETTINGS_SUMMARY, STAFF, STAFF_COUNTS, TASKS, TEAMS, TODAY, TOMORROW, TRENDS, UPCOMING_SHIFTS, USER, } from "@/data/mockData" import { averageHourlyCost, averageRating, averageUtilisation, clientsAtRisk, complianceFlagCount, filledShifts, grossMargin, highPriorityTasks, invoiceTotals, money, money2, num, openTasks, orderFillRate, overdueInvoices, payrollAnomalies, percentOfStaff, pipelineValue, staffCoverage, staffWithComplianceIssues, totalPlacesShort, unavailableStaff, understaffedOrders, understaffedShifts, unreadMessages, } from "@/data/selectors" import type { Tone } from "@/data/types" import type { ActionId, Block, OwliverContext, OwliverMessage } from "./types" /* =========================================================================== Owliver's simulated intelligence. There is no model and no network here. Every reply is assembled from the shared mock data, so Owliver can never quote a figure the dashboard contradicts. Replies are deterministic: the same question always answers the same way. =========================================================================== */ const severityTone: Record = { High: "bad", Medium: "warn", Low: "primary", } /* ============================ shared builders ============================ */ function coverageTable(): Block { const s = STAFF_COUNTS return { type: "table", columns: ["Status", "Count", "Share"], rows: [ { cells: [{ dot: "good", label: "Active" }, s.active, `${percentOfStaff(s.active)}%`] }, { cells: [{ dot: "warn", label: "On Break" }, s.onBreak, `${percentOfStaff(s.onBreak)}%`] }, { cells: [{ dot: "bad", label: "Unavailable" }, s.unavailable, `${percentOfStaff(s.unavailable)}%`] }, { cells: ["Total", s.total, "100%"], summary: true }, ], } } function understaffedTable(): Block { const rows = understaffedShifts().map((r) => ({ cells: [ r.ref.replace("ORD-", ""), r.location, `${r.filled}/${r.required}`, { badge: `-${r.required - r.filled}`, tone: "bad" as Tone }, ], })) return { type: "table", columns: ["Order", "Location", "Filled", "Short"], rows, } } function complianceTable(): Block { return { type: "table", columns: ["Risk", "Staff", "Severity", "Expires"], rows: COMPLIANCE_RISKS.map((c) => ({ cells: [ c.category, c.staffCount, { badge: c.severity, tone: severityTone[c.severity] }, c.expires.replace(" 2025", ""), ], })), } } /* ========================== the opening exchange ========================== */ export function buildInitialConversation(): OwliverMessage[] { const short = totalPlacesShort() return [ { id: "seed-0", author: "owliver", blocks: [ { type: "say", lines: [ `Good ${TODAY.partOfDay}, ${USER.name} ๐Ÿ‘‹`, "I've analysed today's workforce activity.", ], }, { type: "alerts", items: [ { icon: "orders", tone: "warn", text: `${understaffedShifts().length} understaffed orders`, note: `${short} places short today`, }, { icon: "clock", tone: "bad", text: `${ORDER_SUMMARY.startingSoon} shifts starting soon`, note: "Within the next two hours", }, { icon: "shield", tone: "primary", text: `${complianceFlagCount()} compliance flags`, note: "Review before assignment", }, ], }, ], }, { id: "seed-1", author: "user", text: "Which orders are understaffed?" }, { id: "seed-2", author: "owliver", blocks: [ { type: "say", lines: [`**${understaffedShifts().length} of ${SCHEDULE.length} live orders are understaffed.**`], }, { type: "card", icon: "orders", title: "Understaffed Orders", body: [understaffedTable()], }, { type: "recommend", text: "Castleford Logistics has the largest staffing gap โ€” a 22:00 night shift with only 1 of 4 places filled. Owen Bradley and 2 Pool B operatives are available.", }, { type: "actions", items: [ { label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Orders", action: "open-orders", variant: "outline" }, ], }, ], }, ] } /** The first line Owliver opens with on each page (section 6). */ export const CONTEXT_OPENING: Record = { home: "I've analysed today's workforce activity.", performance: "I've reviewed this week's workforce performance.", orders: "I've reviewed today's orders.", availability: "I've checked who's available right now.", tasks: "I've reviewed the task board.", staff: "I've reviewed the staff directory.", compliance: "I've checked your compliance register.", benefits: "I've reviewed benefit enrolment.", clients: "I've reviewed your client accounts.", invoices: "I've reviewed your billing position.", payroll: "I've reviewed this week's payroll.", reports: "I've reviewed the reporting pack.", messages: "I've reviewed your conversations.", settings: "I've checked your workspace configuration.", } /* ======================= page-aware suggested prompts ==================== */ export const QUICK_PROMPTS: Record = { home: [ "Which orders are understaffed?", "How is workforce coverage?", "What needs attention today?", "Give me a workforce summary", ], performance: [ "What changed this week?", "Show utilization trend", "Explain the performance change", "Which area needs attention?", ], orders: [ "Which orders are understaffed?", "Which order has the biggest staffing gap?", "Which orders need attention?", ], availability: [ "Who is available today?", "Where do we need shift coverage?", "Show upcoming availability", ], tasks: [ "Which tasks are overdue?", "Which tasks are unassigned?", "What needs attention?", ], staff: [ "Find available staff", "Show staff by role", "Who is working today?", "Show staff profiles", ], compliance: [ "What compliance risks need attention?", "Show expiring certificates", "Which staff are affected?", "Show high-severity issues", ], benefits: [ "Show benefits overview", "Which benefits need attention?", "Show staff benefit status", "What needs review?", ], clients: [ "Show client overview", "Which clients need attention?", "Show active clients", "Show client activity", ], invoices: [ "Which invoices are overdue?", "Show invoice summary", "What is ready for review?", "Show billing signals", ], payroll: [ "Summarise payroll", "Which staff cost the most?", "Are there any payroll anomalies?", ], reports: [ "Summarise the latest report", "Show important findings", "What changed?", "What should I review?", ], messages: [ "Show unread messages", "What needs a response?", "Show recent messages", "Which conversations need attention?", ], settings: [ "Show workspace settings", "Review notifications", "Review integrations", "Show account settings", ], } /* ====================== page-aware opening context ======================= */ export function contextGreeting(context: OwliverContext): Block[] { switch (context) { case "performance": return [ { type: "say", lines: [`Workforce performance for ${TODAY.short}.`] }, { type: "metrics", items: [ { label: "Utilisation", value: `${averageUtilisation()}%`, sub: "Across 16 tracked staff", tone: "primary" }, { label: "Fill rate", value: `${orderFillRate()}%`, sub: "Live orders", tone: "warn" }, { label: "Avg rating", value: averageRating(), sub: "Out of 5.0", tone: "good" }, ], }, ] case "orders": return [ { type: "say", lines: [`${ORDER_SUMMARY.today} orders today โ€” ${understaffedShifts().length} understaffed.`] }, { type: "card", icon: "orders", title: "Understaffed Orders", body: [understaffedTable()] }, { type: "actions", items: [{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }] }, ] case "availability": return [ { type: "say", lines: [`${STAFF_COUNTS.active} of ${STAFF_COUNTS.total} staff are active right now.`] }, { type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] }, ] case "compliance": return [ { type: "say", lines: [`${complianceFlagCount()} compliance flags across ${COMPLIANCE_RISKS.length} risk categories.`] }, { type: "card", icon: "shield", title: "Open Compliance Risks", body: [complianceTable()] }, { type: "actions", items: [{ label: "Open Compliance", action: "open-compliance", variant: "outline" }] }, ] case "payroll": return [ { type: "say", lines: [`Week 38 payroll is ${money(PAYROLL_SUMMARY.total)} across ${num(PAYROLL_SUMMARY.workedHours)} hours.`] }, { type: "metrics", items: [ { label: "Gross", value: money(PAYROLL_SUMMARY.total), sub: "Pending approval", tone: "primary" }, { label: "Hours", value: num(PAYROLL_SUMMARY.workedHours), sub: "Worked this week" }, { label: "Anomalies", value: String(payrollAnomalies().length), sub: "Need review", tone: "warn" }, ], }, ] case "reports": return [ { type: "say", lines: [`${REPORTS.length} reports available. Margin is holding at ${REPORTS_SUMMARY.marginPct}%.`] }, { type: "metrics", items: [ { label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" }, { label: "Payroll", value: money(REPORTS_SUMMARY.monthlyPayroll), sub: "This month" }, { label: "Margin", value: `${REPORTS_SUMMARY.marginPct}%`, sub: "Gross", tone: "primary" }, ], }, ] case "invoices": { const t = invoiceTotals() return [ { type: "say", lines: [`${money(t.outstanding)} outstanding across ${INVOICES.filter((i) => i.status !== "Paid" && i.status !== "Draft").length} invoices.`] }, { type: "metrics", items: [ { label: "Outstanding", value: money(t.outstanding), sub: "Sent + overdue", tone: "warn" }, { label: "Overdue", value: money(t.overdue), sub: `${overdueInvoices().length} invoices`, tone: "bad" }, { label: "Draft", value: money(t.draft), sub: "Ready to send", tone: "primary" }, ], }, ] } case "clients": return [ { type: "say", lines: [`${CLIENTS.length} client accounts โ€” ${clientsAtRisk().length} need attention.`] }, { type: "card", icon: "building", title: "Accounts Needing Attention", body: [ { type: "table", columns: ["Client", "Status", "Orders"], rows: clientsAtRisk().map((c) => ({ cells: [c.name, { badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }, c.activeOrders], })), }, ], }, ] case "tasks": { const blocked = TASKS.filter((t) => t.status === "Blocked") const dueToday = openTasks().filter((t) => /Today/.test(t.due)) return [ { type: "say", lines: [`${openTasks().length} open tasks โ€” ${highPriorityTasks().length} high priority, ${blocked.length} blocked.`] }, { type: "metrics", items: [ { label: "Due today", value: String(dueToday.length), sub: "Across all owners", tone: "warn" }, { label: "Blocked", value: String(blocked.length), sub: "Waiting on a dependency", tone: "bad" }, { label: "High", value: String(highPriorityTasks().length), sub: "Priority tasks", tone: "primary" }, ], }, { type: "card", icon: "check", title: "Workload by Owner", body: [ { type: "table", columns: ["Owner", "Open", "High"], rows: Array.from( openTasks().reduce((m, t) => { const cur = m.get(t.assignee) ?? { open: 0, high: 0 } cur.open += 1 if (t.priority === "High") cur.high += 1 m.set(t.assignee, cur) return m }, new Map()) ).map(([owner, v]) => ({ cells: [owner, v.open, v.high] })), }, ], }, { type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] }, ] } case "staff": return [ { type: "say", lines: [`${STAFF_COUNTS.total} staff tracked โ€” ${staffCoverage()}% active.`] }, { type: "card", icon: "users", title: "Today's Staff Coverage", body: [coverageTable()] }, { type: "card", icon: "shield", title: "Certification Watch", body: [ { type: "table", columns: ["Staff", "Standing", "Note"], rows: STAFF.filter((m) => m.compliance !== "Verified").map((m) => ({ cells: [ m.name, { badge: m.compliance, tone: m.compliance === "Expired" ? ("bad" as Tone) : ("warn" as Tone) }, m.complianceNote ?? "", ], })), }, ], }, { type: "recommend", text: "Owen Bradley and Henry Adeyemi are the least-utilised verified staff with logistics cover โ€” the best candidates for tonight's Castleford gap.", }, { type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] }, ] case "benefits": { const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0) const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0) return [ { type: "say", lines: [`${BENEFITS.length} benefit plans, ${enrolled} total enrolments.`] }, { type: "metrics", items: [ { label: "Enrolments", value: String(enrolled), sub: "Across all plans", tone: "primary" }, { label: "Monthly cost", value: money(cost), sub: "Employer funded" }, { label: "In review", value: String(BENEFITS.filter((b) => b.status === "Review").length), sub: "Plans", tone: "warn" }, ], }, ] } case "messages": return [ { type: "say", lines: [`${unreadMessages()} unread messages across ${CONVERSATIONS.length} conversations.`] }, { type: "alerts", items: CONVERSATIONS.filter((c) => c.unread > 0).map((c) => ({ icon: "sparkles" as const, tone: "warn" as Tone, text: `${c.name} โ€” ${c.unread} unread`, note: c.messages[c.messages.length - 1].text, })), }, ] case "settings": return [ { type: "say", lines: ["Workspace configuration for Legendary Staffing Pool A."] }, { type: "alerts", items: [ { icon: "check", tone: "good", text: "Workforce rules configured", note: "40h cap, 1.5x overtime, 14-day compliance window" }, { icon: "shield", tone: "warn", text: "2 integrations not connected", note: "BambooHR and Stripe are still available" }, { icon: "users", tone: "primary", text: "5 workspace members", note: "1 admin, 3 managers, 1 billing" }, ], }, ] case "home": default: return [] } } /* ============================ the reply engine =========================== */ interface Rule { match: RegExp /** The page this topic belongs to. Asked elsewhere, Owliver hands it back. */ owner: OwliverContext /** Extra pages where the topic is genuinely in-scope. */ scope?: OwliverContext[] reply: () => Block[] } const RULES: Rule[] = [ { match: /understaff|short|gap|uncovered order|fill.*order/i, owner: "orders", reply: () => [ { type: "say", lines: [`**${understaffedShifts().length} of ${SCHEDULE.length} live orders are understaffed**, ${totalPlacesShort()} places short in total.`] }, { type: "card", icon: "orders", title: "Understaffed Orders", body: [understaffedTable()] }, { type: "recommend", text: `${understaffedOrders()[1]?.venue ?? "Castleford Logistics Hub"} has the largest staffing gap. Filling all three shifts protects ${money(understaffedOrders().reduce((s, o) => s + o.value, 0))} of order value.`, }, { type: "actions", items: [ { label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Orders", action: "open-orders", variant: "outline" }, ], }, ], }, { match: /tomorrow|uncovered/i, owner: "availability", reply: () => [ { type: "say", lines: ["**Tomorrow's cover** โ€” 4 staff scheduled, 2 need action."] }, { type: "card", icon: "clock", title: "Tomorrow's Roster", body: [ { type: "table", columns: ["Staff", "Role", "Start", "Status"], rows: TOMORROW.map((t) => ({ cells: [ t.name, t.role, t.shift, { badge: t.status, tone: t.status === "Confirmed" ? ("good" as Tone) : t.status === "Unavailable" ? ("bad" as Tone) : ("warn" as Tone), }, ], })), }, ], }, { type: "recommend", text: "Jonah Whitfield is unavailable for the 08:00 driver slot and Priya Raman is still unconfirmed for 16:00. Both need replacing before tonight." }, { type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] }, ], }, { match: /compliance|certificat|document|expir|risk signal/i, owner: "compliance", reply: () => [ { type: "say", lines: [`**${complianceFlagCount()} compliance flags** across ${COMPLIANCE_RISKS.length} categories.`] }, { type: "card", icon: "shield", title: "Open Compliance Risks", body: [complianceTable()] }, { type: "details", summary: "Who is affected?", body: [ { type: "table", columns: ["Staff", "Issue"], rows: COMPLIANCE_RISKS.flatMap((c) => c.staffNames.map((n) => ({ cells: [n, c.risk] }))), }, ], }, { type: "recommend", text: "Tomas Ferreira's forklift certification expires on 17 Sep โ€” he is rostered to Castleford tomorrow at 06:00. Reassign or fast-track the renewal." }, { type: "actions", items: [{ label: "Open Compliance", action: "open-compliance" }] }, ], }, { match: /payroll|wage|labour cost|labor cost|cost per hour|gross pay/i, owner: "payroll", reply: () => [ { type: "say", lines: [`**Week 38 payroll** is ${money(PAYROLL_SUMMARY.total)} across ${num(PAYROLL_SUMMARY.workedHours)} worked hours.`] }, { type: "metrics", items: [ { label: "Gross pay", value: money(PAYROLL_SUMMARY.total), sub: "Pending approval", tone: "primary" }, { label: "Cost / hour", value: money2(averageHourlyCost()), sub: "Blended rate" }, { label: "Gross margin", value: `${grossMargin()}%`, sub: `On ${money(PAYROLL_SUMMARY.revenue)} revenue`, tone: "good" }, ], }, { type: "card", icon: "money", title: "Cost by Department", body: [ { type: "chart", kind: "bar", unit: "$", data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.cost })), }, { type: "table", columns: ["Department", "Hours", "Cost"], rows: [ ...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours, money(b.cost)] })), { cells: ["Total", PAYROLL_SUMMARY.workedHours, money(PAYROLL_SUMMARY.total)], summary: true }, ], }, ], }, { type: "details", summary: `${payrollAnomalies().length} anomalies detected`, body: [ { type: "alerts", items: payrollAnomalies().map((a) => ({ icon: "money" as const, tone: "warn" as Tone, text: a.name, note: a.anomaly ?? "", })), }, ], }, { type: "actions", items: [{ label: "Open Payroll", action: "open-payroll", variant: "outline" }] }, ], }, { match: /anomal/i, owner: "payroll", reply: () => [ { type: "say", lines: [`I found **${payrollAnomalies().length} payroll anomalies** in week 38.`] }, { type: "card", icon: "money", title: "Payroll Anomalies", body: [ { type: "table", columns: ["Staff", "Hours", "Gross", "Flag"], rows: payrollAnomalies().map((a) => ({ cells: [a.name, a.hours, money2(a.gross), { badge: "Review", tone: "warn" as Tone }], })), }, ], }, { type: "recommend", text: "Marcus Vance exceeded 40 hours and needs overtime sign-off before the run can be approved." }, { type: "actions", items: [{ label: "Open Payroll", action: "open-payroll" }] }, ], }, { match: /available right now|who.*available|availability|allocation/i, owner: "availability", reply: () => [ { type: "say", lines: [`**${STAFF_COUNTS.active} staff are active**, ${STAFF_COUNTS.onBreak} on break and ${STAFF_COUNTS.unavailable} unavailable.`] }, { type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] }, { type: "card", icon: "users", title: "Unavailable Staff", body: [ { type: "table", columns: ["Staff", "Role", "Next available"], rows: unavailableStaff().map((s) => ({ cells: [s.name, s.role, s.nextShift] })), }, ], }, { type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] }, ], }, { match: /unavailable/i, owner: "availability", reply: () => [ { type: "say", lines: [`**${unavailableStaff().length} staff are unavailable** in the tracked roster.`] }, { type: "card", icon: "users", title: "Unavailable Staff", body: [ { type: "table", columns: ["Staff", "Role", "Next available"], rows: unavailableStaff().map((s) => ({ cells: [s.name, s.role, s.nextShift] })), }, ], }, { type: "recommend", text: "Samuel Okoro has missed 3 consecutive shifts and his manual handling certificate has expired โ€” worth a welfare check before re-rostering." }, ], }, { match: /utilisation|utilization|trend|performance/i, owner: "performance", reply: () => [ { type: "say", lines: [`Utilisation is averaging **${averageUtilisation()}%** across the tracked roster.`] }, { type: "card", icon: "chart", title: "Utilisation This Week", body: [ { type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })), }, ], }, { type: "card", icon: "chart", title: "Department Performance", body: [ { type: "table", columns: ["Department", "Hours", "Margin"], rows: DEPARTMENT_METRICS.map((d) => ({ cells: [d.name.split(" & ")[0], num(d.hours), `${d.margin}%`], })), }, ], }, { type: "recommend", text: "Saturday peaked at 96% utilisation but fill rate dropped to 86% โ€” demand is outrunning available supply at weekends." }, { type: "actions", items: [{ label: "Open Reports", action: "open-reports", variant: "outline" }] }, ], }, { match: /attendance/i, owner: "performance", reply: () => [ { type: "say", lines: ["**Attendance this week** is averaging 95%."] }, { type: "card", icon: "chart", title: "Attendance Trend", body: [ { type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.attendance })), }, ], }, { type: "recommend", text: "Saturday dipped to 92%, the lowest of the week, against the highest rostered hours." }, ], }, { match: /fill rate/i, owner: "performance", scope: ["orders"], reply: () => [ { type: "say", lines: [`Live order fill rate is **${orderFillRate()}%**.`] }, { type: "card", icon: "chart", title: "Fill Rate This Week", body: [ { type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.fillRate })), }, ], }, { type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] }, ], }, { match: /department.*best|best.*department/i, owner: "performance", reply: () => [ { type: "say", lines: ["**Event & Hospitality** leads on margin at 43.1%."] }, { type: "card", icon: "chart", title: "Margin by Department", body: [ { type: "chart", kind: "bar", unit: "%", data: DEPARTMENT_METRICS.map((d) => ({ label: d.name.split(" & ")[0], value: d.margin })), }, { type: "table", columns: ["Department", "Revenue", "Cost", "Margin"], rows: DEPARTMENT_METRICS.map((d) => ({ cells: [d.name.split(" & ")[0], money(d.revenue), money(d.cost), `${d.margin}%`], })), }, ], }, ], }, { match: /highest value|order value|revenue.*order|pipeline/i, owner: "orders", reply: () => [ { type: "say", lines: [`**${money(pipelineValue())}** of order value is in flight.`] }, { type: "card", icon: "money", title: "Highest Value Orders", body: [ { type: "table", columns: ["Order", "Client", "Value", "Status"], rows: [...ORDERS] .filter((o) => o.status !== "Completed") .sort((a, b) => b.value - a.value) .slice(0, 5) .map((o) => ({ cells: [ o.id, o.client.split(" ")[0], money(o.value), { badge: o.status, tone: o.status === "Understaffed" ? ("bad" as Tone) : ("good" as Tone) }, ], })), }, ], }, ], }, { match: /invoice|outstanding|overdue|billing/i, owner: "invoices", reply: () => { const t = invoiceTotals() return [ { type: "say", lines: [`**${money(t.outstanding)} outstanding**, of which ${money(t.overdue)} is overdue.`] }, { type: "metrics", items: [ { label: "Draft", value: money(t.draft), sub: "Ready to send", tone: "primary" }, { label: "Sent", value: money(t.sent), sub: "Awaiting payment" }, { label: "Overdue", value: money(t.overdue), sub: `${overdueInvoices().length} invoices`, tone: "bad" }, ], }, { type: "card", icon: "file", title: "Overdue Invoices", body: [ { type: "table", columns: ["Invoice", "Client", "Due", "Amount"], rows: overdueInvoices().map((i) => ({ cells: [i.id, i.client.split(" ")[0], i.due, money(i.amount)], })), }, ], }, { type: "recommend", text: "Castleford Freight is both an at-risk account and the largest overdue balance at $5,940. Worth a call before the next night shift is confirmed." }, { type: "actions", items: [{ label: "Open Invoices", action: "open-invoices" }] }, ] }, }, { match: /client.*risk|risk.*client|at risk/i, owner: "clients", reply: () => [ { type: "say", lines: [`**${clientsAtRisk().length} client accounts** need attention.`] }, { type: "card", icon: "building", title: "Accounts at Risk", body: [ { type: "table", columns: ["Client", "Status", "Revenue YTD", "Owner"], rows: clientsAtRisk().map((c) => ({ cells: [ c.name.split(" ")[0], { badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }, money(c.revenueYtd), c.owner.split(" ")[0], ], })), }, ], }, { type: "recommend", text: "Castleford Freight combines an at-risk status, an overdue invoice and tonight's most understaffed shift. Highest priority account this week." }, { type: "actions", items: [{ label: "Open Clients", action: "open-clients", variant: "outline" }] }, ], }, { match: /client revenue|revenue.*client|top client/i, owner: "clients", reply: () => [ { type: "say", lines: ["**Revenue by client**, year to date."] }, { type: "card", icon: "chart", title: "Top Client Accounts", body: [ { type: "chart", kind: "bar", unit: "$", data: [...CLIENTS] .sort((a, b) => b.revenueYtd - a.revenueYtd) .slice(0, 5) .map((c) => ({ label: c.name.split(" ")[0], value: c.revenueYtd })), }, ], }, ], }, { match: /client.*staff|demand/i, owner: "clients", reply: () => [ { type: "say", lines: ["**Staffing demand by client**, across live orders."] }, { type: "card", icon: "users", title: "Staff Demand", body: [ { type: "table", columns: ["Client", "Orders", "Staff needed"], rows: [...CLIENTS] .filter((c) => c.staffDemand > 0) .sort((a, b) => b.staffDemand - a.staffDemand) .map((c) => ({ cells: [c.name.split(" ")[0], c.activeOrders, c.staffDemand] })), }, ], }, ], }, { match: /benefit|enrol|enroll/i, owner: "benefits", reply: () => { const enrolled = BENEFITS.reduce((s, b) => s + b.enrolled, 0) const cost = BENEFITS.reduce((s, b) => s + b.monthlyCost, 0) return [ { type: "say", lines: [`**${enrolled} enrolments** across ${BENEFITS.length} plans, costing ${money(cost)} a month.`] }, { type: "card", icon: "chart", title: "Enrolment by Plan", body: [ { type: "chart", kind: "bar", data: BENEFITS.map((b) => ({ label: b.name.split(" ")[0], value: b.enrolled })), }, { type: "table", columns: ["Plan", "Enrolled", "Eligible", "Status"], rows: BENEFITS.map((b) => ({ cells: [ b.name, b.enrolled, b.eligible, { badge: b.status, tone: b.status === "Review" ? ("warn" as Tone) : b.status === "Closed" ? ("neutral" as Tone) : ("good" as Tone) }, ], })), }, ], }, { type: "recommend", text: "Enhanced Holiday Accrual is in review with only 19 of 32 eligible staff enrolled โ€” the lowest take-up of any open plan." }, ] }, }, { match: /high priority|priority task|my task/i, owner: "tasks", reply: () => [ { type: "say", lines: [`**${highPriorityTasks().length} high priority tasks** are open.`] }, { type: "card", icon: "check", title: "High Priority Tasks", body: [ { type: "table", columns: ["Task", "Category", "Due"], rows: highPriorityTasks().map((t) => ({ cells: [t.title.slice(0, 34) + (t.title.length > 34 ? "โ€ฆ" : ""), t.category, t.due], })), }, ], }, { type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] }, ], }, { match: /blocked/i, owner: "tasks", reply: () => { const blocked = TASKS.filter((t) => t.status === "Blocked") return [ { type: "say", lines: [`**${blocked.length} tasks are blocked.**`] }, { type: "card", icon: "alert", title: "Blocked Tasks", body: [ { type: "alerts", items: blocked.map((t) => ({ icon: "check" as const, tone: "warn" as Tone, text: t.title, note: `${t.assignee} ยท due ${t.due}`, })), }, ], }, { type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] }, ] }, }, { match: /report|finding/i, owner: "reports", reply: () => [ { type: "say", lines: [`**${REPORTS.length} reports** are available across three categories.`] }, { type: "metrics", items: [ { label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" }, { label: "Profit", value: money(REPORTS_SUMMARY.grossProfit), sub: `${REPORTS_SUMMARY.marginPct}% margin`, tone: "primary" }, { label: "Overtime", value: money(REPORTS_SUMMARY.overtimeSpend), sub: "This month", tone: "warn" }, ], }, { type: "card", icon: "chart", title: "Revenue vs Cost by Department", body: [ { type: "table", columns: ["Department", "Revenue", "Cost", "Margin"], rows: [ ...DEPARTMENT_METRICS.map((d) => ({ cells: [d.name.split(" & ")[0], money(d.revenue), money(d.cost), `${d.margin}%`], })), { cells: [ "Total", money(DEPARTMENT_METRICS.reduce((s, d) => s + d.revenue, 0)), money(DEPARTMENT_METRICS.reduce((s, d) => s + d.cost, 0)), `${REPORTS_SUMMARY.marginPct}%`, ], summary: true, }, ], }, ], }, { type: "recommend", text: "Facilities & Cleaning is the weakest margin at 39.8% while carrying the fewest hours โ€” a rate review is the clearest lever.", }, { type: "actions", items: [{ label: "Open Reports", action: "open-reports" }] }, ], }, { match: /schedule|roster today|today's shifts|shifts today/i, owner: "orders", scope: ["availability"], reply: () => [ { type: "say", lines: [ `**Today's schedule** โ€” ${SCHEDULE.length} shifts, ${filledShifts().length} fully staffed.`, ], }, { type: "card", icon: "clock", title: "Today's Schedule", body: [ { type: "table", columns: ["Time", "Location", "Staffing", "Status"], rows: [...SCHEDULE] .sort((a, b) => a.time.localeCompare(b.time)) .map((r) => ({ cells: [ r.time, r.location, `${r.filled}/${r.required}`, r.filled >= r.required ? { badge: "Filled", tone: "good" as Tone } : { badge: `${r.required - r.filled} short`, tone: "warn" as Tone }, ], })), }, ], }, ...(understaffedShifts().length > 0 ? ([ { type: "recommend", text: `${understaffedShifts().length} of today's shifts still need cover โ€” ${totalPlacesShort()} places in total. The 22:00 Castleford night shift is the tightest.`, }, { type: "actions", items: [ { label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Orders", action: "open-orders", variant: "outline" }, ], }, ] as Block[]) : ([ { type: "say", lines: ["Every shift today is fully staffed โ€” nothing needs cover."], }, ] as Block[])), ], }, { match: /attention|today|summary|status|what.*need/i, owner: "home", reply: () => [ { type: "say", lines: [`Here's what needs attention on ${TODAY.label}.`] }, { type: "metrics", items: [ { label: "Attention", value: String(understaffedShifts().length + ORDER_SUMMARY.startingSoon), sub: "Items to action", tone: "warn" }, { label: "Coverage", value: `${staffCoverage()}%`, sub: `${STAFF_COUNTS.active} of ${STAFF_COUNTS.total} active`, tone: "good" }, { label: "Compliance", value: String(complianceFlagCount()), sub: "Open flags", tone: "bad" }, ], }, { type: "card", icon: "alert", title: "Priority Alerts", body: [ { type: "alerts", items: [ { icon: "orders", tone: "warn", text: `${understaffedShifts().length} understaffed orders`, note: `${totalPlacesShort()} places short today` }, { icon: "clock", tone: "bad", text: `${ORDER_SUMMARY.startingSoon} shifts starting soon`, note: UPCOMING_SHIFTS[0].location }, { icon: "shield", tone: "primary", text: `${complianceFlagCount()} compliance flags`, note: "Review before assignment" }, { icon: "file", tone: "warn", text: `${overdueInvoices().length} overdue invoices`, note: money(invoiceTotals().overdue) }, ], }, ], }, { type: "actions", items: [ { label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Task Board", action: "open-tasks", variant: "outline" }, ], }, ], }, ] /* =========================================================================== Page-specific rules. These win over the shared topic rules, so a phrase like "what needs attention?" answers about tasks on the Task Board and about the whole workforce on Home. =========================================================================== */ const CONTEXT_RULES: Partial> = { home: [ { match: /coverage|how is workforce|workforce coverage/i, owner: "home", reply: () => [ { type: "say", lines: [`**${staffCoverage()}% of the workforce is active today** โ€” ${STAFF_COUNTS.active} of ${STAFF_COUNTS.total}.`] }, { type: "card", icon: "flow", title: "Staff Allocation Flow", body: [{ type: "flow" }] }, { type: "card", icon: "users", title: "Coverage Detail", body: [ { type: "table", columns: ["Status", "Count", "Share"], rows: [ { cells: [{ dot: "good", label: "Active" }, STAFF_COUNTS.active, `${percentOfStaff(STAFF_COUNTS.active)}%`] }, { cells: [{ dot: "warn", label: "On Break" }, STAFF_COUNTS.onBreak, `${percentOfStaff(STAFF_COUNTS.onBreak)}%`] }, { cells: [{ dot: "bad", label: "Unavailable" }, STAFF_COUNTS.unavailable, `${percentOfStaff(STAFF_COUNTS.unavailable)}%`] }, { cells: ["Total", STAFF_COUNTS.total, "100%"], summary: true }, ] }, ], }, ], }, ], performance: [ { match: /biggest change|caused|daily breakdown|affected area/i, owner: "performance", reply: () => { const swing = TRENDS.map((t, i) => ({ t, delta: i === 0 ? 0 : t.utilisation - TRENDS[i - 1].utilisation })) .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))[0] const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0] return [ { type: "say", lines: [`The biggest single move was **${swing.t.label}**, ${swing.delta > 0 ? "up" : "down"} ${Math.abs(swing.delta)} points to ${swing.t.utilisation}%.`] }, { type: "card", icon: "chart", title: "Daily Breakdown", body: [ { type: "table", columns: ["Day", "Utilisation", "Attendance", "Fill rate"], rows: TRENDS.map((t) => ({ cells: [t.label, `${t.utilisation}%`, `${t.attendance}%`, `${t.fillRate}%`] })) }, ], }, { type: "recommend", text: `${weakest.name} carries the weakest margin at ${weakest.margin}% and is the area most exposed to this swing.` }, ] }, }, { match: /compare|last week|previous week/i, owner: "performance", reply: () => { const avg = Math.round(TRENDS.reduce((a, t) => a + t.utilisation, 0) / TRENDS.length) return [ { type: "say", lines: [`This week averaged **${avg}% utilisation** against ${averageUtilisation()}% for the tracked roster overall.`] }, { type: "metrics", items: [ { label: "This week", value: `${avg}%`, sub: "Average utilisation", tone: "primary" }, { label: "Roster", value: `${averageUtilisation()}%`, sub: "All tracked staff", tone: "good" }, { label: "Fill rate", value: `${orderFillRate()}%`, sub: "Live orders", tone: "warn" }, ], }, { type: "card", icon: "chart", title: "Utilisation This Week", body: [{ type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })) }] }, ] }, }, { match: /review next|what should we review/i, owner: "performance", reply: () => { const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0] return [ { type: "say", lines: [`Review **${weakest.name}** first โ€” lowest margin and fewest hours.`] }, { type: "card", icon: "chart", title: "Review Order", body: [ { type: "table", columns: ["Department", "Hours", "Margin"], rows: [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin).map((d) => ({ cells: [d.name, num(d.hours), `${d.margin}%`] })) }, ], }, { type: "recommend", text: `Weekend fill rate is the second item โ€” Saturday ran ${TRENDS[5].utilisation}% utilisation against a ${TRENDS[5].fillRate}% fill rate.` }, ] }, }, { match: /what changed|explain|why|change this week/i, owner: "performance", reply: () => { const first = TRENDS[0], last = TRENDS[TRENDS.length - 1] const peak = [...TRENDS].sort((a, b) => b.utilisation - a.utilisation)[0] return [ { type: "say", lines: [`**Utilisation moved from ${first.utilisation}% on ${first.label} to ${last.utilisation}% on ${last.label}.**`] }, { type: "metrics", items: [ { label: "Peak", value: `${peak.utilisation}%`, sub: peak.label, tone: "primary" }, { label: "Attendance", value: `${Math.round(TRENDS.reduce((a, t) => a + t.attendance, 0) / TRENDS.length)}%`, sub: "Weekly average", tone: "good" }, { label: "Fill rate", value: `${Math.round(TRENDS.reduce((a, t) => a + t.fillRate, 0) / TRENDS.length)}%`, sub: "Weekly average", tone: "warn" }, ], }, { type: "card", icon: "chart", title: "Utilisation This Week", body: [{ type: "chart", kind: "line", unit: "%", data: TRENDS.map((t) => ({ label: t.label, value: t.utilisation })) }] }, { type: "recommend", text: `${peak.label} peaked at ${peak.utilisation}% utilisation while fill rate fell to ${peak.fillRate}% โ€” demand is outrunning available supply at the end of the week.` }, ] }, }, { match: /area needs attention|which area|needs attention|attention/i, owner: "performance", reply: () => { const weakest = [...DEPARTMENT_METRICS].sort((a, b) => a.margin - b.margin)[0] return [ { type: "say", lines: [`**${weakest.name}** is the weakest area this week.`] }, { type: "card", icon: "chart", title: "Department Performance", body: [ { type: "table", columns: ["Department", "Hours", "Margin"], rows: DEPARTMENT_METRICS.map((d) => ({ cells: [d.name.split(" & ")[0], num(d.hours), `${d.margin}%`] })) }, ], }, { type: "recommend", text: `${weakest.name} carries the fewest hours (${num(weakest.hours)}) at the lowest margin (${weakest.margin}%) โ€” a rate review is the clearest lever.` }, ] }, }, ], orders: [ { match: /attention|need/i, owner: "orders", reply: () => [ { type: "say", lines: [`**${understaffedOrders().length} orders need attention** โ€” ${totalPlacesShort()} places short in total.`] }, { type: "card", icon: "orders", title: "Orders Needing Attention", body: [ { type: "table", columns: ["Order", "Client", "Staffing", "Value"], rows: understaffedOrders().map((o) => ({ cells: [o.id, o.client.split(" ")[0], `${o.filled}/${o.required}`, money(o.value)] })) }, ], }, { type: "actions", items: [{ label: "Resolve Understaffed Shifts", action: "resolve-understaffed" }, { label: "Open Orders", action: "open-orders", variant: "outline" }] }, ], }, { match: /soonest|starts first|next order|earliest/i, owner: "orders", reply: () => { const next = [...SCHEDULE].sort((a, b) => a.time.localeCompare(b.time))[0] return [ { type: "say", lines: [`The next order to start is **${next.ref}** at ${next.time}, ${next.location}.`] }, { type: "card", icon: "clock", title: "Starting Order", body: [ { type: "table", columns: ["Time", "Order", "Location", "Staffing"], rows: [{ cells: [next.time, next.ref, next.location, `${next.filled}/${next.required}`] }] }, ], }, { type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] }, ] }, }, ], availability: [ { match: /cover|next shift|who can/i, owner: "availability", reply: () => { const candidates = [...STAFF] .filter((m) => m.status !== "Unavailable" && m.compliance === "Verified") .sort((a, b) => a.utilisation - b.utilisation) .slice(0, 4) return [ { type: "say", lines: ["**Best cover for the next shift**, by lowest current utilisation."] }, { type: "card", icon: "users", title: "Cover Candidates", body: [ { type: "table", columns: ["Staff", "Role", "Utilisation"], rows: candidates.map((m) => ({ cells: [m.name, m.role, `${m.utilisation}%`] })) }, ], }, { type: "recommend", text: `${candidates[0]?.name} has the most headroom at ${candidates[0]?.utilisation}% utilisation and is compliance-verified.` }, ] }, }, { match: /upcoming|later today|next few hours/i, owner: "availability", reply: () => [ { type: "say", lines: ["**Upcoming shifts** across today."] }, { type: "card", icon: "clock", title: "Upcoming Shifts", body: [ { type: "table", columns: ["When", "Location", "Staff"], rows: UPCOMING_SHIFTS.map((u) => ({ cells: [u.when.replace("Starting ", ""), u.location, u.count] })) }, ], }, { type: "actions", items: [{ label: "Open Staff Availability", action: "open-availability", variant: "outline" }] }, ], }, ], tasks: [ { match: /overdue|due today|late/i, owner: "tasks", reply: () => { const due = openTasks().filter((t) => /Today/.test(t.due)) return [ { type: "say", lines: [`**${due.length} tasks are due today.**`] }, { type: "card", icon: "check", title: "Due Today", body: [ { type: "table", columns: ["Task", "Due", "Owner"], rows: due.map((t) => ({ cells: [t.title.slice(0, 30) + (t.title.length > 30 ? "โ€ฆ" : ""), t.due.replace(" Today", ""), t.assigneeInitials] })) }, ], }, { type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] }, ] }, }, { match: /unassigned|no owner|nobody/i, owner: "tasks", reply: () => { const byOwner = new Map() openTasks().forEach((t) => byOwner.set(t.assignee, (byOwner.get(t.assignee) ?? 0) + 1)) return [ { type: "say", lines: ["Every open task has an owner โ€” nothing is unassigned."] }, { type: "card", icon: "check", title: "Open Tasks by Owner", body: [ { type: "table", columns: ["Owner", "Open tasks"], rows: [...byOwner].map(([o, n]) => ({ cells: [o, n] })) }, ], }, ] }, }, { match: /attention|what needs/i, owner: "tasks", reply: () => { const blocked = TASKS.filter((t) => t.status === "Blocked") return [ { type: "say", lines: [`**${highPriorityTasks().length} high priority** and **${blocked.length} blocked** tasks need attention.`] }, { type: "alerts", items: [ ...highPriorityTasks().map((t) => ({ icon: "check" as const, tone: "bad" as Tone, text: t.title, note: `${t.assignee} ยท due ${t.due}` })), ...blocked.map((t) => ({ icon: "alert" as const, tone: "warn" as Tone, text: t.title, note: `Blocked ยท ${t.assignee}` })), ], }, { type: "actions", items: [{ label: "Open Task Board", action: "open-tasks", variant: "outline" }] }, ] }, }, ], staff: [ { match: /find available|available staff|who is working|working today|on shift/i, owner: "staff", reply: () => { const active = STAFF.filter((m) => m.status === "Active") return [ { type: "say", lines: [`**${active.length} of ${STAFF.length} tracked staff are working today.**`] }, { type: "card", icon: "users", title: "Working Today", body: [ { type: "table", columns: ["Staff", "Role", "Shift"], rows: active.map((m) => ({ cells: [m.name, m.role, m.shift] })) }, ], }, ] }, }, { match: /by role|roles|department/i, owner: "staff", reply: () => { const byDept = new Map() STAFF.forEach((m) => byDept.set(m.department, (byDept.get(m.department) ?? 0) + 1)) return [ { type: "say", lines: ["**Roster by department.**"] }, { type: "card", icon: "users", title: "Staff by Department", body: [ { type: "chart", kind: "bar", data: [...byDept].map(([d, n]) => ({ label: d.split(" & ")[0], value: n })) }, { type: "table", columns: ["Department", "Staff"], rows: [...byDept].map(([d, n]) => ({ cells: [d, n] })) }, ], }, ] }, }, { match: /profile|records|directory|show staff/i, owner: "staff", reply: () => [ { type: "say", lines: [`**${STAFF.length} staff records** tracked in this pool.`] }, { type: "card", icon: "users", title: "Staff Records", body: [ { type: "table", columns: ["Staff", "Role", "Rating", "Standing"], rows: STAFF.slice(0, 8).map((m) => ({ cells: [m.name, m.role, m.rating, { badge: m.compliance, tone: m.compliance === "Verified" ? ("good" as Tone) : m.compliance === "Expired" ? ("bad" as Tone) : ("warn" as Tone) }] })) }, ], }, { type: "details", summary: `${staffWithComplianceIssues().length} records need a compliance review`, body: [{ type: "table", columns: ["Staff", "Note"], rows: staffWithComplianceIssues().map((m) => ({ cells: [m.name, m.complianceNote ?? ""] })) }] }, ], }, ], compliance: [ { match: /affected|which staff|who is affected/i, owner: "compliance", reply: () => [ { type: "say", lines: [`**${complianceFlagCount()} staff flags** across ${COMPLIANCE_RISKS.length} categories.`] }, { type: "card", icon: "shield", title: "Affected Staff", body: [ { type: "table", columns: ["Staff", "Issue", "Severity"], rows: COMPLIANCE_RISKS.flatMap((c) => c.staffNames.map((n) => ({ cells: [n, c.category, { badge: c.severity, tone: severityTone[c.severity] }] }))) }, ], }, { type: "actions", items: [{ label: "Open Compliance", action: "open-compliance", variant: "outline" }] }, ], }, { match: /high.?severity|urgent|critical/i, owner: "compliance", reply: () => { const high = COMPLIANCE_RISKS.filter((c) => c.severity === "High") return [ { type: "say", lines: [`**${high.reduce((n, c) => n + c.staffCount, 0)} staff** carry a high-severity flag.`] }, { type: "alerts", items: high.map((c) => ({ icon: "shield" as const, tone: "bad" as Tone, text: c.risk, note: `${c.staffNames.join(", ")} ยท expires ${c.expires}` })), }, { type: "recommend", text: "High-severity flags block assignment until cleared โ€” resolve these before tonight's rosters are confirmed." }, { type: "actions", items: [{ label: "Open Compliance", action: "open-compliance" }] }, ] }, }, ], benefits: [ { match: /attention|review|needs/i, owner: "benefits", reply: () => { const review = BENEFITS.filter((b) => b.status === "Review") const lowest = [...BENEFITS].sort((a, b) => a.enrolled / a.eligible - b.enrolled / b.eligible)[0] return [ { type: "say", lines: [`**${review.length} plan${review.length === 1 ? "" : "s"} in review**, and take-up is lowest on ${lowest.name}.`] }, { type: "card", icon: "chart", title: "Plans Needing Attention", body: [ { type: "table", columns: ["Plan", "Enrolled", "Eligible", "Status"], rows: [...review, lowest].filter((b, i, a) => a.indexOf(b) === i).map((b) => ({ cells: [b.name, b.enrolled, b.eligible, { badge: b.status, tone: b.status === "Review" ? ("warn" as Tone) : ("good" as Tone) }] })) }, ], }, { type: "recommend", text: `${lowest.name} has only ${lowest.enrolled} of ${lowest.eligible} eligible staff enrolled โ€” the lowest take-up of any plan.` }, ] }, }, { match: /staff benefit|benefit status|tier/i, owner: "benefits", reply: () => { const byTier = new Map() STAFF.forEach((m) => byTier.set(m.benefitsTier, (byTier.get(m.benefitsTier) ?? 0) + 1)) return [ { type: "say", lines: ["**Benefit tier spread** across the tracked roster."] }, { type: "card", icon: "users", title: "Staff Benefit Status", body: [ { type: "table", columns: ["Tier", "Staff", "Share"], rows: [...byTier].map(([t, n]) => ({ cells: [t, n, `${Math.round((n / STAFF.length) * 100)}%`] })) }, ], }, ] }, }, ], clients: [ { match: /overview|summary|active client|show client|client activity/i, owner: "clients", reply: () => [ { type: "say", lines: [`**${CLIENTS.length} client accounts**, ${CLIENTS.filter((c) => c.accountStatus === "Active").length} currently active.`] }, { type: "metrics", items: [ { label: "Revenue YTD", value: money(CLIENTS.reduce((n, c) => n + c.revenueYtd, 0)), sub: "All accounts", tone: "good" }, { label: "Live orders", value: String(CLIENTS.reduce((n, c) => n + c.activeOrders, 0)), sub: "Across clients", tone: "primary" }, { label: "At risk", value: String(clientsAtRisk().length), sub: "Need attention", tone: "warn" }, ], }, { type: "card", icon: "building", title: "Client Accounts", body: [ { type: "table", columns: ["Client", "Orders", "Status"], rows: CLIENTS.map((c) => ({ cells: [c.name.split(" ")[0], c.activeOrders, { badge: c.accountStatus, tone: c.accountStatus === "Active" ? ("good" as Tone) : c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }] })) }, ], }, { type: "actions", items: [{ label: "Open Clients", action: "open-clients", variant: "outline" }] }, ], }, { match: /attention|need/i, owner: "clients", reply: () => [ { type: "say", lines: [`**${clientsAtRisk().length} accounts need attention.**`] }, { type: "card", icon: "building", title: "Accounts at Risk", body: [ { type: "table", columns: ["Client", "Status", "Revenue YTD", "Owner"], rows: clientsAtRisk().map((c) => ({ cells: [c.name.split(" ")[0], { badge: c.accountStatus, tone: c.accountStatus === "At Risk" ? ("bad" as Tone) : ("warn" as Tone) }, money(c.revenueYtd), c.owner.split(" ")[0]] })) }, ], }, { type: "recommend", text: "Castleford Freight is at risk and also carries the largest overdue balance โ€” the highest priority account this week." }, ], }, ], invoices: [ { match: /ready for review|ready|draft/i, owner: "invoices", reply: () => { const drafts = INVOICES.filter((i) => i.status === "Draft") return [ { type: "say", lines: [`**${money(invoiceTotals().draft)} is ready for billing review** across ${drafts.length} draft invoices.`] }, { type: "card", icon: "file", title: "Ready for Review", body: [ { type: "table", columns: ["Invoice", "Client", "Amount"], rows: drafts.map((i) => ({ cells: [i.id, i.client.split(" ")[0], money(i.amount)] })) }, ], }, { type: "actions", items: [{ label: "Open Invoices", action: "open-invoices", variant: "outline" }] }, ] }, }, ], payroll: [ { match: /which staff|staff cost the most|highest paid|who cost/i, owner: "payroll", reply: () => { const top = [...PAYROLL_LINES].sort((a, b) => b.gross - a.gross).slice(0, 6) return [ { type: "say", lines: [`**${top[0].name}** is the highest single cost this week at ${money2(top[0].gross)}.`] }, { type: "card", icon: "money", title: "Highest Cost Staff", body: [ { type: "table", columns: ["Staff", "Hours", "Gross"], rows: top.map((l) => ({ cells: [l.name, l.hours, money2(l.gross)] })) }, ], }, { type: "recommend", text: `${payrollAnomalies().length} pay lines carry an anomaly flag and should be cleared before the run is approved.` }, ] }, }, { match: /worked hours|hours/i, owner: "payroll", reply: () => [ { type: "say", lines: [`**${num(PAYROLL_SUMMARY.workedHours)} hours worked** this week across ${STAFF_COUNTS.total} staff.`] }, { type: "card", icon: "clock", title: "Hours by Department", body: [ { type: "chart", kind: "bar", data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.hours })) }, { type: "table", columns: ["Department", "Hours"], rows: [...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours] })), { cells: ["Total", PAYROLL_SUMMARY.workedHours], summary: true }] }, ], }, ], }, { match: /staff cost|cost/i, owner: "payroll", reply: () => [ { type: "say", lines: [`**${money(PAYROLL_SUMMARY.total)} staff cost** this week, ${money2(averageHourlyCost())} per worked hour.`] }, { type: "card", icon: "money", title: "Cost by Department", body: [ { type: "chart", kind: "bar", unit: "$", data: PAYROLL_SUMMARY.breakdown.map((b) => ({ label: b.group.split(" & ")[0], value: b.cost })) }, { type: "table", columns: ["Department", "Hours", "Cost"], rows: [...PAYROLL_SUMMARY.breakdown.map((b) => ({ cells: [b.group, b.hours, money(b.cost)] })), { cells: ["Total", PAYROLL_SUMMARY.workedHours, money(PAYROLL_SUMMARY.total)], summary: true }] }, ], }, { type: "recommend", text: `Overtime accounts for ${money(PAYROLL_SUMMARY.overtimeSpend)} of the total โ€” Event & Hospitality carries the largest share of hours.` }, ], }, ], reports: [ { match: /what changed|should i review|review|latest/i, owner: "reports", reply: () => [ { type: "say", lines: [`**Margin is holding at ${REPORTS_SUMMARY.marginPct}%** on ${money(REPORTS_SUMMARY.monthlyRevenue)} of monthly revenue.`] }, { type: "metrics", items: [ { label: "Revenue", value: money(REPORTS_SUMMARY.monthlyRevenue), sub: "This month", tone: "good" }, { label: "Profit", value: money(REPORTS_SUMMARY.grossProfit), sub: `${REPORTS_SUMMARY.marginPct}% margin`, tone: "primary" }, { label: "Overtime", value: money(REPORTS_SUMMARY.overtimeSpend), sub: "Worth review", tone: "warn" }, ], }, { type: "card", icon: "chart", title: "Most Recently Updated", body: [ { type: "table", columns: ["Report", "Category", "Updated"], rows: REPORTS.slice(0, 4).map((r) => ({ cells: [r.title, r.category, r.updated] })) }, ], }, { type: "recommend", text: `Billable utilisation is ${REPORTS_SUMMARY.billableUtilisation}% โ€” review the Payroll Cost Breakdown and Invoice Ageing reports first.` }, { type: "actions", items: [{ label: "Open Reports", action: "open-reports", variant: "outline" }] }, ], }, ], messages: [ { match: /unread|response|reply|recent|conversation|attention|message/i, owner: "messages", reply: () => { const needsReply = CONVERSATIONS.filter((c) => c.unread > 0) return [ { type: "say", lines: [`**${unreadMessages()} unread messages** across ${needsReply.length} conversations.`] }, { type: "alerts", items: needsReply.map((c) => ({ icon: "sparkles" as const, tone: "warn" as Tone, text: `${c.name} โ€” ${c.unread} unread`, note: c.messages[c.messages.length - 1].text })), }, { type: "card", icon: "sparkles", title: "Recent Conversations", body: [ { type: "table", columns: ["From", "Last", "Unread"], rows: CONVERSATIONS.map((c) => ({ cells: [c.name, c.lastAt, c.unread === 0 ? "โ€”" : { badge: String(c.unread), tone: "primary" as Tone }] })) }, ], }, { type: "recommend", text: `${needsReply[0]?.name ?? "Marcus Vance"} is waiting on an overtime approval for tonight's night shift โ€” the most time-sensitive reply.` }, ] }, }, ], settings: [ { match: /integration/i, owner: "settings", reply: () => { const c = SETTINGS_SUMMARY.integrations.filter((i) => i.connected) return [ { type: "say", lines: [`**${c.length} of ${SETTINGS_SUMMARY.integrations.length} integrations connected.**`] }, { type: "card", icon: "building", title: "Integrations", body: [ { type: "table", columns: ["Integration", "Category", "Status"], rows: SETTINGS_SUMMARY.integrations.map((i) => ({ cells: [i.name, i.category, { badge: i.connected ? "Connected" : "Not connected", tone: i.connected ? ("good" as Tone) : ("neutral" as Tone) }] })) }, ], }, ] }, }, { match: /notification/i, owner: "settings", reply: () => { const on = SETTINGS_SUMMARY.notifications.filter((n) => n.on) return [ { type: "say", lines: [`**${on.length} of ${SETTINGS_SUMMARY.notifications.length} notifications enabled.**`] }, { type: "card", icon: "alert", title: "Notification Preferences", body: [ { type: "table", columns: ["Alert", "Status"], rows: SETTINGS_SUMMARY.notifications.map((n) => ({ cells: [n.label, { badge: n.on ? "On" : "Off", tone: n.on ? ("good" as Tone) : ("neutral" as Tone) }] })) }, ], }, { type: "recommend", text: "Invoice overdue reminders are off while two invoices are past due โ€” worth enabling." }, ] }, }, { match: /account|member|team/i, owner: "settings", reply: () => [ { type: "say", lines: [`**${SETTINGS_SUMMARY.members.length} workspace members**, signed in as ${USER.fullName}.`] }, { type: "card", icon: "users", title: "Members & Access", body: [ { type: "table", columns: ["Member", "Role", "Access"], rows: SETTINGS_SUMMARY.members.map((m) => ({ cells: [m.name, m.role, { badge: m.access, tone: m.access === "Admin" ? ("primary" as Tone) : ("neutral" as Tone) }] })) }, ], }, ], }, { match: /workspace|setting|configuration|setup/i, owner: "settings", reply: () => { const w = SETTINGS_SUMMARY.workspace return [ { type: "say", lines: [`Workspace configuration for **${TEAMS[0].name}**.`] }, { type: "card", icon: "building", title: "Workspace Settings", body: [ { type: "table", columns: ["Setting", "Value"], rows: [ { cells: ["Plan", w.plan] }, { cells: ["Time zone", w.timeZone] }, { cells: ["Week starts on", w.weekStartsOn] }, { cells: ["Max weekly hours", String(w.maxWeeklyHours)] }, { cells: ["Overtime multiplier", w.overtimeMultiplier] }, { cells: ["Compliance window", w.complianceWindow] }, ] }, ], }, { type: "alerts", items: [{ icon: "shield", tone: "warn", text: `${SETTINGS_SUMMARY.integrations.filter((i) => !i.connected).length} integrations not connected`, note: SETTINGS_SUMMARY.integrations.filter((i) => !i.connected).map((i) => i.name).join(" and ") }] }, ] }, }, ], } /* =========================================================================== Page scoping. Owliver uses one component and one dataset everywhere; only the context changes. A question whose topic belongs to another page is handed back rather than answered with that page's data. =========================================================================== */ const CONTEXT_META: Record< OwliverContext, { label: string; helpsWith: string; action?: ActionId } > = { home: { label: "Home", helpsWith: "anything across the workforce โ€” orders, staffing, compliance, payroll and billing", }, performance: { label: "Performance", helpsWith: "performance, utilisation and workforce trends", }, orders: { label: "Orders", helpsWith: "orders, staffing gaps and order value", action: "open-orders", }, availability: { label: "Staff Availability", helpsWith: "who is available, who is unavailable and shift cover", action: "open-availability", }, tasks: { label: "Task Board", helpsWith: "tasks, priorities and workload", action: "open-tasks", }, staff: { label: "Staff Directory", helpsWith: "the roster, roles and staff records", action: "open-staff", }, compliance: { label: "Compliance", helpsWith: "compliance flags, certificates and affected staff", action: "open-compliance", }, benefits: { label: "Benefits", helpsWith: "benefit plans, enrolment and eligibility", }, clients: { label: "Clients", helpsWith: "client accounts, revenue and account health", action: "open-clients", }, invoices: { label: "Invoices", helpsWith: "invoices, outstanding balances and billing", action: "open-invoices", }, payroll: { label: "Payroll", helpsWith: "payroll totals, worked hours, cost and anomalies", action: "open-payroll", }, reports: { label: "Reports", helpsWith: "reports, findings and recommended actions", action: "open-reports", }, messages: { label: "Messages", helpsWith: "conversations, unread messages and what needs a reply", }, settings: { label: "Settings", helpsWith: "workspace configuration, members, notifications and integrations", }, } /** Hands an out-of-scope question back to the page that owns it. */ function deflect(owner: OwliverContext, context: OwliverContext): Block[] { const target = CONTEXT_META[owner] const here = CONTEXT_META[context] const blocks: Block[] = [ { type: "say", lines: [ `That information belongs to **${target.label}**. I can help with ${here.helpsWith} here.`, ], }, ] if (target.action) { blocks.push({ type: "actions", items: [{ label: `Open ${target.label}`, action: target.action }], }) } return blocks } /** Nothing matched โ€” say what this page can answer. */ function pageFallback(context: OwliverContext): Block[] { return [ { type: "say", lines: [ "I can't answer that from the demo dataset.", `On this page I can help with ${CONTEXT_META[context].helpsWith}.`, ], }, { type: "card", icon: "sparkles", title: "Try asking", body: [ { type: "table", columns: ["Question"], rows: QUICK_PROMPTS[context].map((q) => ({ cells: [q] })), }, ], }, ] } /** * Builds Owliver's reply, scoped to the active page. * * Page-specific rules win first, then the shared topic rules โ€” but a shared * rule only answers if the active page owns it (Home owns everything, being * the overall workforce view). Always deterministic. */ export function respond(prompt: string, context: OwliverContext = "home"): Block[] { const local = CONTEXT_RULES[context]?.find((r) => r.match.test(prompt)) if (local) return local.reply() const rule = RULES.find((r) => r.match.test(prompt)) if (rule) { const inScope = context === "home" || rule.owner === context || rule.owner === "home" || (rule.scope?.includes(context) ?? false) return inScope ? rule.reply() : deflect(rule.owner, context) } return pageFallback(context) } /* =========================================================================== Follow-up suggestions. After every answer Owliver offers the most useful next questions for what was just asked โ€” never the same fixed list, and never outside the active page. A deflection deliberately falls back to the page's own suggestions so the user is not led further off-scope. =========================================================================== */ interface FollowUpBranch { match: RegExp next: string[] } const FOLLOW_UPS: Partial> = { home: [ { match: /understaff|gap|short/i, next: ["How is workforce coverage?", "What needs attention today?", "Summarise payroll this week"] }, { match: /coverage|workforce/i, next: ["Who's uncovered tomorrow?", "Which orders are understaffed?", "Show compliance risks"] }, { match: /attention|summary|today/i, next: ["Which orders are understaffed?", "How is workforce coverage?", "Show compliance risks"] }, { match: /compliance/i, next: ["Which staff are affected?", "What needs attention today?", "How is workforce coverage?"] }, { match: /payroll|cost|hours/i, next: ["Are there payroll anomalies?", "What needs attention today?", "Give me a workforce summary"] }, ], performance: [ { match: /what changed|this week/i, next: ["Explain the performance change", "Show utilization trend", "Which area needs attention?"] }, { match: /explain/i, next: ["What caused the biggest change?", "Show the daily breakdown", "Which area needs attention?"] }, { match: /biggest change|caused|daily breakdown/i, next: ["Show the affected area", "Compare with last week", "What should we review next?"] }, { match: /affected area|which area|attention/i, next: ["Compare with last week", "Show utilization trend", "What should we review next?"] }, { match: /compare|last week/i, next: ["What should we review next?", "Show the daily breakdown", "Which area needs attention?"] }, { match: /trend|utilisation|utilization/i, next: ["What changed this week?", "Which area needs attention?", "Show the daily breakdown"] }, ], orders: [ { match: /understaff|gap|short/i, next: ["Which order has the biggest staffing gap?", "Which order starts soonest?", "Which orders need attention?"] }, { match: /biggest|highest value|value/i, next: ["Which orders are understaffed?", "Which order starts soonest?", "Which orders need attention?"] }, { match: /soonest|starts/i, next: ["Which orders are understaffed?", "Which orders need attention?", "Which order has the highest value?"] }, { match: /attention|need/i, next: ["Which order has the biggest staffing gap?", "Which order starts soonest?", "Which order has the highest value?"] }, ], availability: [ { match: /available/i, next: ["Where do we need shift coverage?", "Who is unavailable?", "Show upcoming availability"] }, { match: /unavailable/i, next: ["Who is available today?", "Where do we need shift coverage?", "Show upcoming availability"] }, { match: /cover|coverage/i, next: ["Who is available today?", "Show upcoming availability", "Who is unavailable?"] }, { match: /upcoming/i, next: ["Where do we need shift coverage?", "Who is available today?", "Who is unavailable?"] }, ], tasks: [ { match: /overdue|due/i, next: ["Show high-priority tasks", "What needs attention?", "Which tasks are unassigned?"] }, { match: /priority/i, next: ["Which tasks are overdue?", "What's blocked right now?", "Which tasks are unassigned?"] }, { match: /attention|blocked/i, next: ["Which tasks are overdue?", "Show high-priority tasks", "Which tasks are unassigned?"] }, { match: /unassigned/i, next: ["Which tasks are overdue?", "Show high-priority tasks", "What needs attention?"] }, ], staff: [ { match: /available|working/i, next: ["Show staff by role", "Show staff profiles", "Who is working today?"] }, { match: /role|department/i, next: ["Find available staff", "Show staff profiles", "Who is working today?"] }, { match: /profile|record/i, next: ["Show staff by role", "Find available staff", "Who is working today?"] }, ], compliance: [ { match: /high.?severity|urgent/i, next: ["Which staff are affected?", "Show expiring certificates", "What compliance risks need attention?"] }, { match: /affected|who/i, next: ["Show high-severity issues", "Show expiring certificates", "What compliance risks need attention?"] }, { match: /expir|certificat/i, next: ["Which staff are affected?", "Show high-severity issues", "What compliance risks need attention?"] }, { match: /risk|attention/i, next: ["Show high-severity issues", "Which staff are affected?", "Show expiring certificates"] }, ], benefits: [ { match: /overview|enrol/i, next: ["Which benefits need attention?", "Show staff benefit status", "What needs review?"] }, { match: /attention|review/i, next: ["Show benefits overview", "Show staff benefit status", "What needs review?"] }, { match: /status|tier/i, next: ["Show benefits overview", "Which benefits need attention?", "What needs review?"] }, ], clients: [ { match: /overview|active|activity/i, next: ["Which clients need attention?", "Show client revenue", "Which clients demand most staff?"] }, { match: /attention|risk/i, next: ["Show client overview", "Show client revenue", "Show active clients"] }, { match: /revenue|demand/i, next: ["Which clients need attention?", "Show client overview", "Show active clients"] }, ], invoices: [ { match: /overdue/i, next: ["Show invoice summary", "What is ready for review?", "Show billing signals"] }, { match: /ready|draft|review/i, next: ["Which invoices are overdue?", "Show invoice summary", "Show billing signals"] }, { match: /summary|billing|outstanding/i, next: ["Which invoices are overdue?", "What is ready for review?", "Show billing signals"] }, ], payroll: [ { match: /summarise|summary|payroll/i, next: ["Which staff cost the most?", "Are there any payroll anomalies?", "Show worked hours"] }, { match: /which staff|cost the most/i, next: ["Are there any payroll anomalies?", "Show worked hours", "Summarise payroll"] }, { match: /anomal/i, next: ["Which staff cost the most?", "Show worked hours", "Summarise payroll"] }, { match: /hours|cost/i, next: ["Which staff cost the most?", "Are there any payroll anomalies?", "Summarise payroll"] }, ], reports: [ { match: /latest|summarise/i, next: ["Show important findings", "What changed?", "What should I review?"] }, { match: /finding/i, next: ["What should I review?", "Summarise the latest report", "What changed?"] }, { match: /changed|review/i, next: ["Show important findings", "Summarise the latest report", "What changed?"] }, ], messages: [ { match: /unread/i, next: ["What needs a response?", "Which conversations need attention?", "Show recent messages"] }, { match: /response|reply/i, next: ["Show unread messages", "Show recent messages", "Which conversations need attention?"] }, { match: /recent|conversation|attention/i, next: ["Show unread messages", "What needs a response?", "Show recent messages"] }, ], settings: [ { match: /workspace|configuration/i, next: ["Review notifications", "Review integrations", "Show account settings"] }, { match: /notification/i, next: ["Review integrations", "Show workspace settings", "Show account settings"] }, { match: /integration/i, next: ["Review notifications", "Show workspace settings", "Show account settings"] }, { match: /account|member/i, next: ["Show workspace settings", "Review notifications", "Review integrations"] }, ], } /** Whether a question is answerable on this page (rather than handed back). */ export function isInScope(prompt: string, context: OwliverContext): boolean { if (CONTEXT_RULES[context]?.some((r) => r.match.test(prompt))) return true const rule = RULES.find((r) => r.match.test(prompt)) if (!rule) return true return ( context === "home" || rule.owner === context || rule.owner === "home" || (rule.scope?.includes(context) ?? false) ) } /** * The next questions to offer after answering `prompt` on `context`. * Always page-scoped, never repeats the question just asked. */ export function followUpsFor(prompt: string, context: OwliverContext): string[] { const pool = QUICK_PROMPTS[context] const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase() // Off-scope answers lead back to this page, not further away. const branch = isInScope(prompt, context) ? FOLLOW_UPS[context]?.find((b) => b.match.test(prompt)) : undefined const picked = (branch?.next ?? pool).filter((q) => !same(q, prompt)) const topped = [...picked, ...pool.filter((q) => !same(q, prompt) && !picked.includes(q))] return topped.slice(0, 3) } /** One call for the panel: the answer plus the next questions to offer. */ export function answer( prompt: string, context: OwliverContext ): { blocks: Block[]; followUps: string[] } { return { blocks: respond(prompt, context), followUps: followUpsFor(prompt, context) } } /** Confirmation Owliver gives after the resolve action runs. */ export function resolvedReply( resolved: { ref: string; location: string; added: number; total: number }[] ): Block[] { const added = resolved.reduce((s, r) => s + r.added, 0) return [ { type: "say", lines: [`Done โ€” I've filled **${added} places** across ${resolved.length} shifts.`] }, { type: "card", icon: "check", title: "Shifts Resolved", body: [ { type: "table", columns: ["Order", "Location", "Added", "Now"], rows: resolved.map((r) => ({ cells: [r.ref, r.location, `+${r.added}`, { badge: `${r.total}/${r.total}`, tone: "good" as Tone }], })), }, ], }, { type: "say", lines: ["Every live order is now fully staffed. The dashboard has updated."] }, { type: "actions", items: [{ label: "Open Orders", action: "open-orders", variant: "outline" }] }, ] }